From c0c576bef550eb0824b8eac0ac0ddb77a8ded64f Mon Sep 17 00:00:00 2001 From: Noor-ul-ain001 Date: Wed, 22 Jul 2026 17:03:10 +0500 Subject: [PATCH] fix(bundler): guard lazy .hostname ValueError in catalog add_source 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) --- .../bundler/commands_impl/catalog_config.py | 11 ++++- tests/unit/test_bundler_catalog_config.py | 41 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/bundler/commands_impl/catalog_config.py b/src/specify_cli/bundler/commands_impl/catalog_config.py index 54839192c4..dadc7e9672 100644 --- a/src/specify_cli/bundler/commands_impl/catalog_config.py +++ b/src/specify_cli/bundler/commands_impl/catalog_config.py @@ -143,6 +143,13 @@ def add_source( raise BundlerError("A catalog url is required.") try: parsed = urlparse(url) + # Read .hostname inside the try: a bracketed-but-invalid IPv6 authority + # (e.g. "https://[not-an-ip]/c.json") parses cleanly under urlparse() on + # Python < 3.14 but raises ValueError lazily on the first .hostname access + # (the raise moved eager into urlparse() only in 3.14). Reading it here + # keeps that ValueError inside the guard instead of leaking a raw + # traceback past the CLI's `except BundlerError`. Reuse the value below. + hostname = parsed.hostname except ValueError as exc: raise BundlerError(f"Invalid catalog url: '{url}'.") from exc if not (parsed.scheme or parsed.path): @@ -161,13 +168,13 @@ def add_source( # netloc — netloc is truthy for host-less URLs like "https://:8080" # or "https://user@". Validating here keeps junk out of # bundle-catalogs.yml instead of failing later at fetch time. - is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + is_localhost = hostname in ("localhost", "127.0.0.1", "::1") if parsed.scheme.lower() != "https" and not is_localhost: raise BundlerError( f"Catalog url must use HTTPS (got {parsed.scheme}://). " "HTTP is only allowed for localhost." ) - if not parsed.hostname: + if not hostname: raise BundlerError(f"Catalog url must be a valid URL with a host: {url}") url = _canonicalize_url(url) diff --git a/tests/unit/test_bundler_catalog_config.py b/tests/unit/test_bundler_catalog_config.py index ddfa81b9ed..5ad6ecbb93 100644 --- a/tests/unit/test_bundler_catalog_config.py +++ b/tests/unit/test_bundler_catalog_config.py @@ -253,6 +253,47 @@ def test_add_source_wraps_invalid_ipv6_as_bundler_error(tmp_path: Path): cc.add_source(project, "https://[::1/c.json", policy="install-allowed", priority=50) +def test_add_source_wraps_bracketed_non_ip_host_as_bundler_error(tmp_path: Path): + # 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; the raise moved eager into urlparse() + # in 3.14. add_source must surface its own BundlerError on every supported + # version, never leak a raw ValueError past the CLI's `except BundlerError`. + project = tmp_path / "proj" + (project / ".specify").mkdir(parents=True) + with pytest.raises(BundlerError, match="Invalid catalog url"): + cc.add_source(project, "https://[not-an-ip]/c.json", policy="install-allowed", priority=50) + + +def test_add_source_wraps_lazy_hostname_valueerror(tmp_path: Path, monkeypatch): + # Simulate the Python < 3.14 shape explicitly (independent of the running + # interpreter): urlparse() succeeds but .hostname raises ValueError lazily. + # This is the exact path the fix guards; it fails with a raw ValueError if + # .hostname is read outside the try/except. + from urllib.parse import urlparse as _real_urlparse + + class _LazyHostnameRaiser: + def __init__(self, parsed): + self._parsed = parsed + + @property + def hostname(self): + raise ValueError("simulated lazy IPv6 hostname failure") + + def __getattr__(self, name): + return getattr(self._parsed, name) + + def _fake_urlparse(url, *args, **kwargs): + return _LazyHostnameRaiser(_real_urlparse(url, *args, **kwargs)) + + monkeypatch.setattr(cc, "urlparse", _fake_urlparse) + + project = tmp_path / "proj" + (project / ".specify").mkdir(parents=True) + with pytest.raises(BundlerError, match="Invalid catalog url"): + cc.add_source(project, "https://example.com/c.json", policy="install-allowed", priority=50) + + def test_remove_source_does_not_crash_on_invalid_ipv6(tmp_path: Path): project = tmp_path / "proj" (project / ".specify").mkdir(parents=True)