Skip to content

Commit 77528dc

Browse files
fix(bundler): decode a downloaded (non-zip) bundle manifest as UTF-8 (#4190)
_download_remote_manifest's non-zip branch fed the downloaded bytes straight to `yaml.safe_load(io.BytesIO(raw))`. PyYAML's Reader auto-detects a UTF-16 BOM on a byte stream, so a well-formed UTF-16 bundle.yml (a realistic PowerShell `Out-File`/`>` output) was silently *accepted* here, while `yamlio.load_yaml` decodes local sources strictly as UTF-8 and rejects the identical content with "Could not read ...". BEFORE: a UTF-16 manifest downloaded via `bundle info`/`install` parses successfully -- exit code 0, no warning. AFTER: rejected with "... could not be read: ..." -- exit code 1, matching local directory and .zip sources. This is the same divergence, in the sibling branch of the same function, that was just fixed for the .zip case in commit 56aec8a (PR #3958): "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." That fix covered `_local_manifest_source`'s `.zip` branch (which this same function calls for zip artifacts); the direct raw-YAML-download branch a few lines below it had the identical bug. Also drops the now-unused `import io` from this function. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 2f96c91 commit 77528dc

2 files changed

Lines changed: 46 additions & 2 deletions

File tree

src/specify_cli/commands/bundle/__init__.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -934,7 +934,6 @@ def _download_remote_manifest(
934934
expected_sha256: str | None = None,
935935
):
936936
"""Fetch a remote bundle artifact over HTTPS and extract its manifest."""
937-
import io
938937
import tempfile
939938
from pathlib import PurePosixPath
940939
from urllib.parse import urlparse as _urlparse
@@ -1038,7 +1037,20 @@ def _validate_redirect(old_url: str, new_url: str) -> None:
10381037
)
10391038
return manifest
10401039

1041-
data = _yaml.safe_load(io.BytesIO(raw))
1040+
# Decode as UTF-8 explicitly -- matching yamlio.load_yaml's contract --
1041+
# instead of feeding PyYAML the raw byte stream. PyYAML's Reader
1042+
# auto-detects a UTF-16 BOM and would silently *accept* a manifest
1043+
# that the local directory/bundle.yml sources reject, letting this
1044+
# remote-download path diverge from them (see the sibling .zip fix
1045+
# for _local_manifest_source, which had the identical bug).
1046+
try:
1047+
text = raw.decode("utf-8")
1048+
except UnicodeError as exc:
1049+
raise BundlerError(
1050+
f"Downloaded content for bundle '{entry_id}' from "
1051+
f"{_source_desc} could not be read: {exc}"
1052+
) from exc
1053+
data = _yaml.safe_load(text)
10421054
return BundleManifest.from_dict(data)
10431055
except BundlerError:
10441056
raise

tests/contract/test_bundle_cli.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,38 @@ def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None
786786
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
787787

788788

789+
def test_bundle_info_rejects_utf16_remote_manifest_like_local_sources(project: Path):
790+
"""A downloaded (non-zip) bundle.yml must be decoded strictly as UTF-8.
791+
792+
``yamlio.load_yaml`` decodes local ``bundle.yml`` sources strictly as
793+
UTF-8, so a well-formed UTF-16 manifest (a realistic PowerShell
794+
``Out-File`` output) is rejected. Feeding the downloaded bytes straight
795+
to ``yaml.safe_load(io.BytesIO(raw))`` let PyYAML's Reader honour the
796+
UTF-16 BOM and silently *accept* the same manifest instead, diverging
797+
from local/zip sources (the zip branch of this same download path was
798+
already fixed for the identical bug).
799+
"""
800+
api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99"
801+
manifest_yaml_utf16 = yaml.safe_dump(valid_manifest_dict()).encode("utf-16")
802+
803+
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
804+
return FakeBundleResponse(manifest_yaml_utf16, url=api_asset_url)
805+
806+
catalog = project / "catalog.json"
807+
write_catalog_file(
808+
catalog,
809+
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)},
810+
)
811+
_make_catalog_config(catalog, project)
812+
813+
with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
814+
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"])
815+
816+
assert result.exit_code == 1
817+
output_flat = " ".join(result.output.split())
818+
assert "could not be read" in output_flat.lower()
819+
820+
789821
def test_bundle_info_passes_through_api_asset_url(project: Path):
790822
"""bundle info passes a direct GitHub API asset URL through with octet-stream."""
791823
api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/77"

0 commit comments

Comments
 (0)