Skip to content

Commit b762e4b

Browse files
mnriemCopilot
andcommitted
fix(bundler): address PR review — annotations, Windows paths, HTTPS, errors, reproducible builds
Resolves automated review feedback on #3070: - validator: drop redundant string-quoting on ReferenceChecker's `str | None` return so the annotation evaluates as a real union under `from __future__ import annotations`. - adapters: normalize Windows drive-letter paths (e.g. C:\...) to the local-file branch so offline file catalogs resolve on Windows. - adapters: enforce HTTPS (HTTP only for localhost) and require a host on remote catalog URLs before any network call, mirroring specify_cli.catalogs URL validation (MITM/downgrade protection). - adapters: pass `origin` to loads_json for local files and HTTP payloads so JSON parse errors name the real source instead of <string>. - manifest: parse component `priority` defensively, raising an actionable BundlerError on non-integer values instead of a raw ValueError. - packager: write zip members with a fixed timestamp + permissions so identical inputs yield byte-for-byte identical artifacts (genuinely reproducible builds), and strengthen the determinism test accordingly. Adds regression tests for priority validation, plain-HTTP/host rejection, and byte-level artifact reproducibility (111 bundler tests pass; ruff clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d074d0e commit b762e4b

7 files changed

Lines changed: 92 additions & 8 deletions

File tree

src/specify_cli/bundler/models/manifest.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,15 +202,30 @@ def _parse_refs(kind: str, raw: Any) -> list[ComponentRef]:
202202
for item in raw:
203203
if not isinstance(item, dict):
204204
raise BundlerError(f"Each provides.{kind} entry must be a mapping.")
205-
priority = item.get("priority")
205+
priority = _parse_priority(kind, item.get("priority"))
206206
refs.append(
207207
ComponentRef(
208208
kind=kind,
209209
id=str(item.get("id", "")).strip(),
210210
version=(str(item["version"]).strip() if item.get("version") else None),
211211
source=(str(item["source"]).strip() if item.get("source") else None),
212-
priority=(int(priority) if priority is not None else None),
212+
priority=priority,
213213
strategy=(str(item["strategy"]).strip() if item.get("strategy") else None),
214214
)
215215
)
216216
return refs
217+
218+
219+
def _parse_priority(kind: str, raw: Any) -> int | None:
220+
if raw is None:
221+
return None
222+
if isinstance(raw, bool) or not isinstance(raw, (int, str)):
223+
raise BundlerError(
224+
f"provides.{kind} priority must be an integer, got {raw!r}."
225+
)
226+
try:
227+
return int(raw)
228+
except (TypeError, ValueError):
229+
raise BundlerError(
230+
f"provides.{kind} priority must be an integer, got {raw!r}."
231+
) from None

src/specify_cli/bundler/services/adapters.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"""
1111
from __future__ import annotations
1212

13+
import re
1314
from pathlib import Path
1415
from urllib.parse import urlparse
1516

@@ -36,6 +37,33 @@
3637

3738
HTTP_TIMEOUT_SECONDS = 10
3839

40+
# Windows absolute paths like ``C:\catalog.json`` parse with a single-letter
41+
# ``scheme`` under urlparse; treat them as local files rather than URLs.
42+
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]")
43+
44+
45+
def _is_windows_drive_path(url: str) -> bool:
46+
return bool(_WINDOWS_DRIVE_RE.match(url))
47+
48+
49+
def _validate_remote_url(source_id: str, url: str) -> None:
50+
"""Restrict remote catalogs to HTTPS (HTTP only for localhost) with a host.
51+
52+
Mirrors ``specify_cli.catalogs`` URL validation to avoid MITM/downgrade
53+
issues before any network call.
54+
"""
55+
parsed = urlparse(url)
56+
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
57+
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
58+
raise BundlerError(
59+
f"Catalog '{source_id}' URL must use HTTPS (got {parsed.scheme}://). "
60+
"HTTP is only allowed for localhost."
61+
)
62+
if not parsed.netloc:
63+
raise BundlerError(
64+
f"Catalog '{source_id}' URL must be a valid URL with a host: {url}"
65+
)
66+
3967

4068
def make_catalog_fetcher(*, allow_network: bool = True):
4169
"""Return a fetcher callable suitable for :class:`CatalogStack`.
@@ -55,18 +83,19 @@ def fetch(source: CatalogSource) -> dict:
5583
raise BundlerError(f"Unknown built-in catalog '{url}'.")
5684
return payload
5785

58-
if scheme in ("", "file"):
86+
if scheme in ("", "file") or _is_windows_drive_path(url):
5987
path = Path(parsed.path if scheme == "file" else url)
6088
if not path.exists():
6189
raise BundlerError(f"Catalog file not found: {path}")
62-
return loads_json(path.read_text(encoding="utf-8"))
90+
return loads_json(path.read_text(encoding="utf-8"), origin=str(path))
6391

6492
if scheme in ("http", "https"):
6593
if not allow_network:
6694
raise BundlerError(
6795
f"Network access disabled; cannot fetch catalog '{source.id}' "
6896
f"from {url}."
6997
)
98+
_validate_remote_url(source.id, url)
7099
return _http_get_json(url)
71100

72101
raise BundlerError(f"Unsupported catalog URL scheme: {url}")
@@ -82,7 +111,7 @@ def _http_get_json(url: str) -> dict:
82111
raw = response.read().decode("utf-8")
83112
except Exception as exc: # noqa: BLE001
84113
raise BundlerError(f"Failed to fetch catalog from {url}: {exc}") from exc
85-
return loads_json(raw)
114+
return loads_json(raw, origin=url)
86115

87116

88117
class DefaultPrimitiveInstaller:

src/specify_cli/bundler/services/packager.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
# Files/dirs never included in an artifact.
2020
EXCLUDE_NAMES = {".git", "__pycache__", ".DS_Store"}
2121

22+
# Fixed member timestamp (zip epoch) for reproducible, byte-stable artifacts.
23+
_FIXED_TIMESTAMP = (1980, 1, 1, 0, 0, 0)
24+
2225

2326
@dataclass
2427
class BuildResult:
@@ -53,7 +56,13 @@ def build_bundle(
5356
for file_path in files:
5457
# Confinement: every packaged file must live under bundle_dir.
5558
ensure_within(bundle_dir, file_path)
56-
archive.write(file_path, file_path.relative_to(bundle_dir).as_posix())
59+
arcname = file_path.relative_to(bundle_dir).as_posix()
60+
# Fixed timestamp + permissions so identical inputs yield a
61+
# byte-for-byte identical artifact (reproducible builds).
62+
info = zipfile.ZipInfo(filename=arcname, date_time=_FIXED_TIMESTAMP)
63+
info.compress_type = zipfile.ZIP_DEFLATED
64+
info.external_attr = 0o644 << 16
65+
archive.writestr(info, file_path.read_bytes())
5766

5867
return BuildResult(artifact_path=artifact_path, file_count=len(files))
5968

src/specify_cli/bundler/services/validator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from ..models.manifest import BundleManifest, ComponentRef
1515

1616
# A reference checker returns None when resolvable, or an error string.
17-
ReferenceChecker = Callable[[ComponentRef], "str | None"]
17+
ReferenceChecker = Callable[[ComponentRef], str | None]
1818

1919

2020
@dataclass

tests/contract/test_manifest_schema.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
"""
66
from __future__ import annotations
77

8+
import pytest
9+
10+
from specify_cli.bundler import BundlerError
811
from specify_cli.bundler.models.manifest import BundleManifest
912
from tests.bundler_helpers import valid_manifest_dict
1013

@@ -51,6 +54,13 @@ def test_invalid_preset_strategy_is_rejected():
5154
assert any("strategy" in e for e in errors)
5255

5356

57+
def test_non_integer_priority_raises_actionable_error():
58+
data = valid_manifest_dict()
59+
data["provides"]["presets"][0]["priority"] = "high"
60+
with pytest.raises(BundlerError, match="priority must be an integer"):
61+
BundleManifest.from_dict(data)
62+
63+
5464
def test_non_step_components_must_be_pinned():
5565
data = valid_manifest_dict()
5666
data["provides"]["extensions"] = [{"id": "ext-unpinned"}]

tests/integration/test_bundler_offline.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,18 @@ def test_missing_file_catalog_errors_offline(tmp_path: Path):
5252
stack = CatalogStack([_src("local", str(tmp_path / "nope.json"))], fetcher)
5353
with pytest.raises(BundlerError):
5454
stack.resolve("anything")
55+
56+
57+
def test_plain_http_remote_rejected_before_network():
58+
# HTTPS is required for non-localhost catalogs; reject http:// up front.
59+
fetcher = make_catalog_fetcher(allow_network=True)
60+
stack = CatalogStack([_src("remote", "http://example.com/catalog.json")], fetcher)
61+
with pytest.raises(BundlerError, match="must use HTTPS"):
62+
stack.resolve("anything")
63+
64+
65+
def test_remote_url_without_host_rejected():
66+
fetcher = make_catalog_fetcher(allow_network=True)
67+
stack = CatalogStack([_src("remote", "https:///catalog.json")], fetcher)
68+
with pytest.raises(BundlerError, match="valid URL with a host"):
69+
stack.resolve("anything")

tests/unit/test_bundler_packager.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,5 +61,11 @@ def test_build_is_deterministic(tmp_path: Path):
6161
first = build_bundle(bundle, output_dir=tmp_path / "out1")
6262
second = build_bundle(bundle, output_dir=tmp_path / "out2")
6363
with zipfile.ZipFile(first.artifact_path) as a, zipfile.ZipFile(second.artifact_path) as b:
64-
# Same files, same order (sorted) — stable, reproducible manifests.
64+
# Same files, same order (sorted).
6565
assert a.namelist() == b.namelist()
66+
# Fixed timestamps + permissions make each member byte-identical.
67+
for left, right in zip(a.infolist(), b.infolist()):
68+
assert left.date_time == right.date_time
69+
assert left.external_attr == right.external_attr
70+
# The whole artifact is byte-for-byte reproducible.
71+
assert first.artifact_path.read_bytes() == second.artifact_path.read_bytes()

0 commit comments

Comments
 (0)