Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/specify_cli/bundler/commands_impl/catalog_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions tests/unit/test_bundler_catalog_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down