diff --git a/src/specify_cli/commands/bundle/__init__.py b/src/specify_cli/commands/bundle/__init__.py index 10df8aca14..efa768ba83 100644 --- a/src/specify_cli/commands/bundle/__init__.py +++ b/src/specify_cli/commands/bundle/__init__.py @@ -752,8 +752,6 @@ def _local_manifest_source(arg: str): return BundleManifest.from_file(manifest_path) if candidate.suffix == ".zip": - import io - import yaml as _yaml from ..._download_security import open_zip_bounded, read_zip_member_limited @@ -771,8 +769,20 @@ def _local_manifest_source(arg: str): error_type=BundlerError, label="bundle manifest", ) + # The bounded-zip helpers above keep archive failures inside the + # BundlerError contract, but the manifest bytes need the same + # treatment as yamlio.load_yaml: decode as UTF-8 explicitly — + # feeding PyYAML the byte stream would let its Reader auto-detect + # a UTF-16 BOM and accept a manifest the directory and bundle.yml + # sources reject. + try: + text = raw.decode("utf-8") + except UnicodeError as exc: + raise BundlerError( + f"Could not read bundle.yml inside '{candidate}': {exc}" + ) from exc try: - data = _yaml.safe_load(io.BytesIO(raw)) + data = _yaml.safe_load(text) except _yaml.YAMLError as exc: # The sibling directory/bundle.yml branches reach YAML through # load_yaml(), which turns a parse failure into a BundlerError. This diff --git a/tests/integration/test_bundler_local_install.py b/tests/integration/test_bundler_local_install.py index 5ca873c78a..630c981a73 100644 --- a/tests/integration/test_bundler_local_install.py +++ b/tests/integration/test_bundler_local_install.py @@ -62,6 +62,39 @@ def test_local_source_rejects_unknown_file(tmp_path: Path): _local_manifest_source(str(weird)) +def test_local_source_zip_non_utf8_manifest_raises_bundler_error(tmp_path: Path): + """Undecodable bundle.yml bytes inside a .zip must raise BundlerError. + + The manifest bytes are decoded as UTF-8 explicitly, matching + ``yamlio.load_yaml``'s "Could not read ..." contract, instead of + escaping as a raw ``UnicodeDecodeError``/``ReaderError`` traceback. + """ + artifact = tmp_path / "demo.zip" + with zipfile.ZipFile(artifact, "w") as archive: + archive.writestr("bundle.yml", b"\xff\xfe bundle \xc3\x28\n") + + with pytest.raises(BundlerError, match="Could not read"): + _local_manifest_source(str(artifact)) + + +def test_local_source_zip_utf16_manifest_rejected_like_directory(tmp_path: Path): + """A well-formed UTF-16 manifest must fail the same way in a .zip. + + ``yamlio.load_yaml`` decodes strictly as UTF-8, so a UTF-16 bundle.yml + (the realistic PowerShell ``Out-File`` output) is rejected when read + from a directory. Feeding the zip bytes straight to PyYAML would let + its Reader honour the UTF-16 BOM and *accept* the same manifest, + making zip and directory sources diverge. + """ + artifact = tmp_path / "demo.zip" + manifest_text = "bundle:\n id: demo-bundle\n version: 1.0.0\n" + with zipfile.ZipFile(artifact, "w") as archive: + archive.writestr("bundle.yml", manifest_text.encode("utf-16")) + + with pytest.raises(BundlerError, match="Could not read"): + _local_manifest_source(str(artifact)) + + def test_install_bundled_extension_from_zip_offline(tmp_path: Path): """End-to-end: build → install (offline, local .zip) → list → remove.""" project = make_project(tmp_path / "proj")