From 311c017209aaa1dc2314c4735e09cc3bca5cf0bf Mon Sep 17 00:00:00 2001 From: Noor-ul-ain001 Date: Tue, 25 Aug 2026 20:18:57 +0500 Subject: [PATCH] fix(init): report a malformed --extension URL cleanly, not raw urllib text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_install_extension_during_init` (src/specify_cli/commands/init.py) parses an --extension URL spec with a bare `urlparse(ext_spec)`. An unterminated or invalid bracketed IPv6 authority (e.g. "https://[not-an-ip]/x.zip") makes urlparse itself raise ValueError — this became eager in Python 3.14 (previously lazy, raised only on `.hostname` access). Since the call was unguarded, `specify init --extension ` reported the raw urllib message ("'not-an-ip' does not appear to be an IPv4 or IPv6 address") instead of an actionable error. Every sibling URL entry point in this codebase already guards this exact case with a clean domain error: extensions/__init__.py, presets/__init__.py, extensions/_commands.py, workflows/catalog.py (the #3435/#3484 lineage). init.py's own `_ext_spec_is_url` classifier next to this function already catches the same ValueError; this call site was the outlier. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01FW9fAYsCBCAgdKWovtSyqt --- src/specify_cli/commands/init.py | 12 +++++++++++- tests/test_init_output_markup.py | 26 +++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 4af9427bfa..028f6ba17f 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -118,7 +118,17 @@ def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_ve manager = ExtensionManager(project_path) # --- URL --- - parsed = urlparse(ext_spec) + # A malformed authority (e.g. an unterminated IPv6 bracket + # "https://[not-an-ip]/x.zip") makes urlparse raise ValueError. This + # function's contract is to raise a clean ValueError the caller can + # display as a tracker error; without this guard, the raw urllib message + # (e.g. "'not-an-ip' does not appear to be an IPv4 or IPv6 address") + # leaked through instead. Mirrors the guard every other URL-accepting + # extension/preset/workflow entry point already has (#3435 lineage). + try: + parsed = urlparse(ext_spec) + except ValueError as exc: + raise ValueError(f"Malformed extension URL: {ext_spec}") from exc if parsed.scheme in ("http", "https"): try: manifest = install_extension_from_url( diff --git a/tests/test_init_output_markup.py b/tests/test_init_output_markup.py index 54576fb33f..2b68a0460c 100644 --- a/tests/test_init_output_markup.py +++ b/tests/test_init_output_markup.py @@ -25,7 +25,10 @@ from typer.testing import CliRunner from specify_cli import app -from specify_cli.commands.init import _shell_quote_arg +from specify_cli.commands.init import ( + _install_extension_during_init, + _shell_quote_arg, +) from tests.conftest import requires_bash @@ -174,3 +177,24 @@ def test_shell_quote_arg_is_host_appropriate(): assert quoted == '"my project"' else: assert quoted == "'my project'" + + +def test_install_extension_during_init_reports_malformed_url_cleanly(tmp_path: Path): + """A malformed extension URL must raise a clean ValueError, not leak the + raw urllib message. + + An unterminated/invalid bracketed IPv6 authority (e.g. + "https://[not-an-ip]/x.zip") makes ``urlparse()`` itself raise + ``ValueError`` (this became eager in Python 3.14; it was previously lazy, + raised only on ``.hostname`` access). ``_install_extension_during_init`` + parsed the spec unguarded, so `specify init --extension ` showed + "failed: 'not-an-ip' does not appear to be an IPv4 or IPv6 address" + instead of an actionable message. Every sibling URL entry point + (extensions/__init__.py, presets/__init__.py, workflows/catalog.py, + extensions/_commands.py) already guards this exact case. + """ + (tmp_path / ".specify").mkdir() + with pytest.raises(ValueError, match="Malformed extension URL"): + _install_extension_during_init( + tmp_path, "https://[not-an-ip]/ext.zip", "1.0.0" + )