Skip to content

Commit 02644f8

Browse files
chuenchen309claude
andcommitted
fix(bundle): surface a clean BundlerError on a malformed bundle download URL
`_download_manifest` and its `_require_https` helper parsed the catalog entry's `download_url` with an unguarded `urlparse(url)`. A malformed authority — e.g. an unclosed IPv6 bracket like `https://[::1` — makes `urlparse` (or `.hostname` on older Pythons) raise a raw `ValueError`. The three `bundle` CLI commands (`info`, `install`, `update`) only catch `BundlerError`, so that `ValueError` escaped as an uncaught traceback. Wrap both parse sites in the same `try/except ValueError -> BundlerError` guard already used by the sibling `_validate_remote_url` (and established by the merged catalog-URL fix #3576), so a bad `download_url` reports a clean, actionable error in every mode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 309166e commit 02644f8

2 files changed

Lines changed: 63 additions & 3 deletions

File tree

src/specify_cli/commands/bundle/__init__.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -765,7 +765,16 @@ def _download_manifest(resolved, *, offline: bool):
765765
f"Catalog entry '{resolved.entry.id}' has no download_url; cannot resolve "
766766
"its manifest."
767767
)
768-
parsed = urlparse(url)
768+
# A malformed authority (e.g. an unclosed IPv6 bracket ``https://[::1``)
769+
# makes urlparse raise ValueError. Surface it as the documented
770+
# BundlerError, like the sibling ``_validate_remote_url``, rather than
771+
# leaking a raw ValueError past the callers, which only catch BundlerError.
772+
try:
773+
parsed = urlparse(url)
774+
except ValueError:
775+
raise BundlerError(
776+
f"Catalog entry '{resolved.entry.id}' has a malformed download_url: {url}"
777+
) from None
769778
scheme = parsed.scheme.lower()
770779

771780
# ``file://`` URLs and bare filesystem paths (including Windows drive paths
@@ -802,8 +811,17 @@ def _download_manifest(resolved, *, offline: bool):
802811
def _require_https(label: str, url: str) -> None:
803812
from urllib.parse import urlparse
804813

805-
parsed = urlparse(url)
806-
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
814+
# urlparse / hostname access raise ValueError on a malformed authority;
815+
# keep the documented BundlerError contract (older Pythons surface this via
816+
# the .hostname access below rather than at the urlparse call).
817+
try:
818+
parsed = urlparse(url)
819+
hostname = parsed.hostname
820+
except ValueError:
821+
raise BundlerError(
822+
f"Refusing to download {label}: URL is malformed: {url}"
823+
) from None
824+
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
807825
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
808826
raise BundlerError(
809827
f"Refusing to download {label} over non-HTTPS URL: {url}"
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Unit tests for malformed download-URL handling in bundle manifest resolution."""
2+
from __future__ import annotations
3+
4+
from types import SimpleNamespace
5+
6+
import pytest
7+
8+
from specify_cli.bundler import BundlerError
9+
from specify_cli.commands.bundle import _download_manifest, _require_https
10+
11+
_MALFORMED_URLS = [
12+
"https://[::1", # unclosed IPv6 bracket
13+
"https://[not-an-ip]/bundle.yml",
14+
]
15+
16+
17+
@pytest.mark.parametrize("url", _MALFORMED_URLS)
18+
def test_download_manifest_rejects_malformed_url_cleanly(url):
19+
"""A malformed download_url must raise BundlerError, not a raw ValueError.
20+
21+
``urlparse`` raises ``ValueError`` on a malformed authority (e.g. an
22+
unclosed IPv6 bracket). The bundle CLI commands only catch BundlerError, so
23+
a raw ValueError would escape as an uncaught traceback. Sibling of the
24+
guarded ``_validate_remote_url`` (adapters) and the merged #3576 fix.
25+
"""
26+
resolved = SimpleNamespace(
27+
entry=SimpleNamespace(id="mybundle", download_url=url)
28+
)
29+
with pytest.raises(BundlerError):
30+
_download_manifest(resolved, offline=True)
31+
32+
33+
@pytest.mark.parametrize("url", _MALFORMED_URLS)
34+
def test_require_https_rejects_malformed_url_cleanly(url):
35+
"""``_require_https`` must also surface BundlerError on a malformed authority.
36+
37+
On older Python versions the ValueError is raised at ``.hostname`` access
38+
rather than at ``urlparse``, so guarding both keeps the contract across the
39+
CI Python matrix.
40+
"""
41+
with pytest.raises(BundlerError):
42+
_require_https("bundle 'x'", url)

0 commit comments

Comments
 (0)