Skip to content

Commit bd90f76

Browse files
fix(bundler): guard lazy .hostname ValueError in catalog add_source (#3644)
A bracketed-but-invalid IPv6 authority (e.g. "https://[not-an-ip]/c.json") parses cleanly under urlparse() on Python < 3.14 and only raises ValueError lazily on the first .hostname access. add_source read parsed.hostname outside the try/except ValueError guard, so on the interpreters spec-kit supports (>=3.11) that raw ValueError leaked past the CLI's `except BundlerError`, surfacing an uncaught traceback instead of the clean "Invalid catalog url" domain error. (The raise moved eager into urlparse() only in 3.14.) Read parsed.hostname inside the try and reuse the value for both the HTTPS/localhost check and the require-host check. This also protects the later _derive_id() call on the same URL. Regression tests: a bracketed-non-IP URL, plus a monkeypatched lazy-.hostname raiser that reproduces the pre-3.14 shape independently of the running interpreter (fails with a raw ValueError before the fix). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b91e30a commit bd90f76

2 files changed

Lines changed: 50 additions & 2 deletions

File tree

src/specify_cli/bundler/commands_impl/catalog_config.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,13 @@ def add_source(
143143
raise BundlerError("A catalog url is required.")
144144
try:
145145
parsed = urlparse(url)
146+
# Read .hostname inside the try: a bracketed-but-invalid IPv6 authority
147+
# (e.g. "https://[not-an-ip]/c.json") parses cleanly under urlparse() on
148+
# Python < 3.14 but raises ValueError lazily on the first .hostname access
149+
# (the raise moved eager into urlparse() only in 3.14). Reading it here
150+
# keeps that ValueError inside the guard instead of leaking a raw
151+
# traceback past the CLI's `except BundlerError`. Reuse the value below.
152+
hostname = parsed.hostname
146153
except ValueError as exc:
147154
raise BundlerError(f"Invalid catalog url: '{url}'.") from exc
148155
if not (parsed.scheme or parsed.path):
@@ -161,13 +168,13 @@ def add_source(
161168
# netloc — netloc is truthy for host-less URLs like "https://:8080"
162169
# or "https://user@". Validating here keeps junk out of
163170
# bundle-catalogs.yml instead of failing later at fetch time.
164-
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
171+
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
165172
if parsed.scheme.lower() != "https" and not is_localhost:
166173
raise BundlerError(
167174
f"Catalog url must use HTTPS (got {parsed.scheme}://). "
168175
"HTTP is only allowed for localhost."
169176
)
170-
if not parsed.hostname:
177+
if not hostname:
171178
raise BundlerError(f"Catalog url must be a valid URL with a host: {url}")
172179

173180
url = _canonicalize_url(url)

tests/unit/test_bundler_catalog_config.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,47 @@ def test_add_source_wraps_invalid_ipv6_as_bundler_error(tmp_path: Path):
253253
cc.add_source(project, "https://[::1/c.json", policy="install-allowed", priority=50)
254254

255255

256+
def test_add_source_wraps_bracketed_non_ip_host_as_bundler_error(tmp_path: Path):
257+
# A bracketed-but-invalid IPv6 authority (e.g. "https://[not-an-ip]/c.json")
258+
# parses cleanly under urlparse() on Python < 3.14 and only raises ValueError
259+
# lazily on the first .hostname access; the raise moved eager into urlparse()
260+
# in 3.14. add_source must surface its own BundlerError on every supported
261+
# version, never leak a raw ValueError past the CLI's `except BundlerError`.
262+
project = tmp_path / "proj"
263+
(project / ".specify").mkdir(parents=True)
264+
with pytest.raises(BundlerError, match="Invalid catalog url"):
265+
cc.add_source(project, "https://[not-an-ip]/c.json", policy="install-allowed", priority=50)
266+
267+
268+
def test_add_source_wraps_lazy_hostname_valueerror(tmp_path: Path, monkeypatch):
269+
# Simulate the Python < 3.14 shape explicitly (independent of the running
270+
# interpreter): urlparse() succeeds but .hostname raises ValueError lazily.
271+
# This is the exact path the fix guards; it fails with a raw ValueError if
272+
# .hostname is read outside the try/except.
273+
from urllib.parse import urlparse as _real_urlparse
274+
275+
class _LazyHostnameRaiser:
276+
def __init__(self, parsed):
277+
self._parsed = parsed
278+
279+
@property
280+
def hostname(self):
281+
raise ValueError("simulated lazy IPv6 hostname failure")
282+
283+
def __getattr__(self, name):
284+
return getattr(self._parsed, name)
285+
286+
def _fake_urlparse(url, *args, **kwargs):
287+
return _LazyHostnameRaiser(_real_urlparse(url, *args, **kwargs))
288+
289+
monkeypatch.setattr(cc, "urlparse", _fake_urlparse)
290+
291+
project = tmp_path / "proj"
292+
(project / ".specify").mkdir(parents=True)
293+
with pytest.raises(BundlerError, match="Invalid catalog url"):
294+
cc.add_source(project, "https://example.com/c.json", policy="install-allowed", priority=50)
295+
296+
256297
def test_remove_source_does_not_crash_on_invalid_ipv6(tmp_path: Path):
257298
project = tmp_path / "proj"
258299
(project / ".specify").mkdir(parents=True)

0 commit comments

Comments
 (0)