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
16 changes: 13 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,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:
Comment thread
marcelsafin marked this conversation as resolved.
# The sibling directory/bundle.yml branches reach YAML through
# load_yaml(), which turns a parse failure into a BundlerError. This
Expand Down
33 changes: 33 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,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")
Expand Down