From a3c07a1ae7452351b479e7c7e38b5781cbddb105 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:13:18 +0200 Subject: [PATCH] fix: decode the zipped manifest as UTF-8 before parsing Review follow-up: feeding PyYAML the byte stream let its Reader honour a UTF-16 BOM and accept a manifest yamlio.load_yaml rejects, so zip and directory sources diverged. Decode raw as UTF-8 (UnicodeError -> BundlerError 'Could not read ...') then parse, and cover a well-formed UTF-16 manifest in the regression tests. Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/commands/bundle/__init__.py | 16 +++++++-- .../integration/test_bundler_local_install.py | 33 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) 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")