Skip to content

Commit e5fb1ea

Browse files
author
Markus
committed
fix(bundler): only fall back to snapshot on transient catalog failures
The builtin catalog fallback previously caught every BundlerError from the online fetch, silently masking malformed JSON, oversized responses, and redirect/URL validation failures with the packaged snapshot. Classify only transient failures (connection errors, timeouts, HTTP 5xx) as unavailable, preserve validation failures, warn when a snapshot is used, and go quiet for deliberate --offline use. Cover both builtin catalogs in unit and integration tests, and assert the first-party catalog wheel mapping. Assisted-by: opencode (model: deepseek-v4.1-flash, autonomous)
1 parent ff6e59f commit e5fb1ea

5 files changed

Lines changed: 229 additions & 78 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ packages = ["src/specify_cli"]
4545
"extensions/agent-context" = "specify_cli/core_pack/extensions/agent-context"
4646
"extensions/assess" = "specify_cli/core_pack/extensions/assess"
4747
"extensions/bug" = "specify_cli/core_pack/extensions/bug"
48-
# Bundled workflows (auto-installed during `specify init` or via first-party bundles)
48+
# Bundled workflows (`specify init` installs only `speckit`; `bugfix`/`assess` are opt-in via first-party bundles)
4949
"workflows/speckit" = "specify_cli/core_pack/workflows/speckit"
5050
"workflows/bugfix" = "specify_cli/core_pack/workflows/bugfix"
5151
"workflows/assess" = "specify_cli/core_pack/workflows/assess"

‎src/specify_cli/bundler/services/adapters.py‎

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from __future__ import annotations
1212

1313
import re
14+
import urllib.error
15+
import warnings
1416
from pathlib import Path
1517
from urllib.parse import ParseResult, urlparse
1618
from urllib.request import url2pathname
@@ -46,6 +48,17 @@
4648

4749
HTTP_TIMEOUT_SECONDS = 10
4850

51+
52+
class _CatalogUnavailable(BundlerError):
53+
"""A built-in catalog could not be reached (transport/availability failure).
54+
55+
Marks only transient fetch failures — connection/DNS errors, timeouts, and
56+
HTTP 5xx — so the built-in catalog fallback does not swallow content or
57+
security validation failures (malformed JSON, oversized or non-UTF-8
58+
bodies, unsafe redirects, HTTP 4xx).
59+
"""
60+
61+
4962
# Windows absolute paths like ``C:\catalog.json`` parse with a single-letter
5063
# ``scheme`` under urlparse; treat them as local files rather than URLs.
5164
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]")
@@ -142,18 +155,27 @@ def fetch(source: CatalogSource) -> dict:
142155
repository_url = _BUILTIN_REPOSITORY_URLS.get(url)
143156
if repository_url is None:
144157
raise BundlerError(f"Unknown built-in catalog '{url}'.")
158+
snapshot_name = _BUILTIN_PACKAGED_SNAPSHOTS[url]
145159
if allow_network:
146160
try:
147161
return _http_get_json(source.id, repository_url)
148-
except BundlerError as exc:
162+
except _CatalogUnavailable as exc:
149163
# Built-in catalogs remain usable when the repository is
150164
# temporarily unavailable; the packaged snapshot is the
151-
# authoritative offline fallback.
165+
# authoritative offline fallback. Only transient fetch
166+
# failures take this path -- content/security validation
167+
# errors propagate so a malformed response is never masked.
168+
warnings.warn(
169+
f"Built-in catalog '{url}' is unavailable ({exc}); "
170+
"using the packaged snapshot.",
171+
UserWarning,
172+
stacklevel=2,
173+
)
152174
try:
153-
return _load_packaged_catalog(_BUILTIN_PACKAGED_SNAPSHOTS[url])
175+
return _load_packaged_catalog(snapshot_name)
154176
except BundlerError as snapshot_exc:
155177
raise snapshot_exc from exc
156-
return _load_packaged_catalog(_BUILTIN_PACKAGED_SNAPSHOTS[url])
178+
return _load_packaged_catalog(snapshot_name)
157179

158180
if scheme == "file":
159181
path = _file_url_to_path(parsed)
@@ -212,7 +234,35 @@ def _validate_redirect(_old_url: str, new_url: str) -> None:
212234
label=f"bundle catalog '{source_id}'",
213235
).decode("utf-8")
214236
except BundlerError:
237+
# Size limits, redirect/URL validation: content or security failures,
238+
# never transient -- must not be downgraded to availability.
215239
raise
240+
except urllib.error.HTTPError as exc:
241+
# urllib raises HTTPError for any non-2xx status; only 5xx is a
242+
# transient server-side availability failure. A 4xx (404, 403, ...)
243+
# is a definitive response and must surface as a hard error.
244+
if exc.code >= 500:
245+
raise _CatalogUnavailable(
246+
f"Failed to fetch catalog from {url}: HTTP {exc.code} {exc.reason}"
247+
) from exc
248+
raise BundlerError(
249+
f"Failed to fetch catalog from {url}: HTTP {exc.code} {exc.reason}"
250+
) from exc
251+
except urllib.error.URLError as exc:
252+
raise _CatalogUnavailable(
253+
f"Failed to fetch catalog from {url}: {exc.reason}"
254+
) from exc
255+
except (TimeoutError, OSError) as exc:
256+
# socket.timeout is TimeoutError; OSError covers connection resets and
257+
# other low-level transport failures not wrapped in URLError.
258+
raise _CatalogUnavailable(
259+
f"Failed to fetch catalog from {url}: {exc}"
260+
) from exc
261+
except UnicodeDecodeError as exc:
262+
# A non-UTF-8 body is a malformed response, not an availability issue.
263+
raise BundlerError(
264+
f"Failed to fetch catalog from {url}: response was not valid UTF-8 ({exc})"
265+
) from exc
216266
except Exception as exc:
217267
raise BundlerError(f"Failed to fetch catalog from {url}: {exc}") from exc
218268
return loads_json(raw, origin=final_url)

‎tests/contract/test_catalog_schema.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,19 @@ def test_wheel_packages_community_bundle_catalog():
229229
)
230230

231231

232+
def test_wheel_packages_firstparty_bundle_catalog():
233+
repo_root = Path(__file__).parents[2]
234+
with (repo_root / "pyproject.toml").open("rb") as pyproject_file:
235+
pyproject = tomllib.load(pyproject_file)
236+
237+
force_include = pyproject["tool"]["hatch"]["build"]["targets"]["wheel"][
238+
"force-include"
239+
]
240+
assert force_include["bundles/catalog.json"] == (
241+
"specify_cli/core_pack/bundles/catalog.json"
242+
)
243+
244+
232245
def test_catalog_entry_rejects_string_tags():
233246
from specify_cli.bundler.models.catalog import CatalogEntry
234247

‎tests/integration/test_bundler_offline.py‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,20 @@ def test_builtin_default_catalog_resolves_first_party_bundles_offline():
4141
assert resolved.install_allowed is True
4242

4343

44-
def test_builtin_catalog_failure_does_not_block_lower_priority_source(monkeypatch):
44+
@pytest.mark.parametrize(
45+
"source_id, builtin_id, builtin_priority, project_priority",
46+
[
47+
pytest.param("default", "builtin://default", 1, 10, id="default"),
48+
pytest.param("community", "builtin://community", 20, 30, id="community"),
49+
],
50+
)
51+
def test_builtin_catalog_failure_does_not_block_lower_priority_source(
52+
monkeypatch, source_id, builtin_id, builtin_priority, project_priority
53+
):
54+
from specify_cli.bundler.services import adapters
55+
4556
def fail_http_get_json(source_id, url):
46-
raise BundlerError("repository unavailable")
57+
raise adapters._CatalogUnavailable("repository unavailable")
4758

4859
monkeypatch.setattr(
4960
"specify_cli.bundler.services.adapters._http_get_json", fail_http_get_json
@@ -53,7 +64,9 @@ def fail_http_get_json(source_id, url):
5364
lambda filename: {"schema_version": "1.0", "bundles": {}},
5465
)
5566

56-
project = _src("project", "https://example.com/catalog.json", priority=10)
67+
project = _src(
68+
"project", "https://example.com/catalog.json", priority=project_priority
69+
)
5770
fetcher = make_catalog_fetcher(allow_network=True)
5871

5972
def fetch_project(source):
@@ -65,10 +78,13 @@ def fetch_project(source):
6578
return fetcher(source)
6679

6780
stack = CatalogStack(
68-
[_src("default", "builtin://default"), project], fetch_project
81+
[_src(source_id, builtin_id, priority=builtin_priority), project],
82+
fetch_project,
6983
)
7084

71-
assert stack.resolve("company").source.id == "project"
85+
with pytest.warns(UserWarning, match="packaged snapshot"):
86+
resolved = stack.resolve("company")
87+
assert resolved.source.id == "project"
7288

7389

7490
def test_builtin_community_catalog_resolves_from_packaged_snapshot_offline():

0 commit comments

Comments
 (0)