diff --git a/src/specify_cli/commands/bundle/__init__.py b/src/specify_cli/commands/bundle/__init__.py index 7476cb41b5..5d8f42be7c 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,7 +769,26 @@ def _local_manifest_source(arg: str): error_type=BundlerError, label="bundle manifest", ) - data = _yaml.safe_load(io.BytesIO(raw)) + # 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 — then parse, mirroring load_yaml's + # "Could not read ..." / "Invalid YAML in ..." messages so a + # manifest inside a .zip fails exactly like the other sources. + 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(text) + except _yaml.YAMLError as exc: + raise BundlerError( + f"Invalid YAML in bundle.yml inside '{candidate}': {exc}" + ) from exc return BundleManifest.from_dict(data) if candidate.name == "bundle.yml" or candidate.suffix in (".yml", ".yaml"): diff --git a/tests/integration/test_bundler_local_install.py b/tests/integration/test_bundler_local_install.py index 164de57006..7a8397354c 100644 --- a/tests/integration/test_bundler_local_install.py +++ b/tests/integration/test_bundler_local_install.py @@ -62,6 +62,56 @@ def test_local_source_rejects_unknown_file(tmp_path: Path): _local_manifest_source(str(weird)) +def test_local_source_zip_invalid_manifest_yaml_raises_bundler_error(tmp_path: Path): + """Malformed YAML inside a .zip's bundle.yml must raise BundlerError. + + The zip open and member read already degrade into BundlerError via the + shared bounded-zip helpers, but the subsequent ``yaml.safe_load`` did + not: an invalid manifest escaped as a raw ``yaml.YAMLError`` traceback, + while the same manifest in a directory or as a plain bundle.yml goes + through ``load_yaml``'s "Invalid YAML in ..." BundlerError contract. + """ + artifact = tmp_path / "demo.zip" + with zipfile.ZipFile(artifact, "w") as archive: + archive.writestr("bundle.yml", "bundle: [unclosed\n") + + with pytest.raises(BundlerError, match="Invalid YAML"): + _local_manifest_source(str(artifact)) + + +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")