Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions src/specify_cli/commands/bundle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Comment thread
marcelsafin marked this conversation as resolved.
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"):
Expand Down
50 changes: 50 additions & 0 deletions tests/integration/test_bundler_local_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down