From 311c017209aaa1dc2314c4735e09cc3bca5cf0bf Mon Sep 17 00:00:00 2001 From: Noor-ul-ain001 Date: Tue, 25 Aug 2026 20:18:57 +0500 Subject: [PATCH 1/3] 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" + ) From 71c2e82fcb72a0b7e636509fa90cba9a0fc2fed4 Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Wed, 2 Sep 2026 00:20:17 +0500 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/commands/init.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 028f6ba17f..ec5903c474 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -127,6 +127,8 @@ def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_ve # extension/preset/workflow entry point already has (#3435 lineage). try: parsed = urlparse(ext_spec) + _ = parsed.hostname + _ = parsed.port except ValueError as exc: raise ValueError(f"Malformed extension URL: {ext_spec}") from exc if parsed.scheme in ("http", "https"): From ad762fe36b4f755524727caf9b97d16dee356876 Mon Sep 17 00:00:00 2001 From: Noor-ul-ain001 Date: Sun, 13 Sep 2026 19:40:21 +0500 Subject: [PATCH 3/3] fix(init): check local-path syntax before parsing --extension as a URL The URL guard added .hostname/.port probing to catch Python 3.11-3.13's lazy authority validation, but it ran unconditionally before the local-path check. On Windows, a spec like "C://[my-ext]" parses with urlparse() as scheme "c" and netloc "[my-ext]" -- a bracketed authority that fails IPv6-literal validation. On Python 3.14 that failure fires from urlparse() itself, so a valid absolute local path was misreported as "Malformed extension URL" before the local-path branch ever ran. Move the local-path check first so urlparse() only ever sees specs that aren't already local paths. Also broadens the guard's own regression test with a monkeypatched-urlparse case covering the lazy (3.11-3.13) .hostname failure on any interpreter, plus a case proving a bracketed Windows-style local path is handled locally, not misclassified as a URL. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01U74yBbvVQCPwB7Ed8Dzeu6 --- src/specify_cli/commands/init.py | 39 +++++++++++-------- tests/test_init_output_markup.py | 65 ++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 15 deletions(-) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index ec5903c474..2ff2977b76 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -117,14 +117,33 @@ def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_ve manager = ExtensionManager(project_path) + # --- Local path --- + # Checked before URL parsing below: on Windows, a single-letter drive + # prefix (e.g. "C://[my-extension]") parses as a URL with scheme "c", + # and urlparse() eagerly validates a bracketed authority on Python 3.14 + # (raising ValueError from the call itself, before .hostname is ever + # touched). Parsing this as a URL first would misreport a valid local + # directory as a malformed extension URL instead of installing it. + if ext_spec.startswith(("./", "../", "/", "~/", ".\\", "..\\")) or Path(ext_spec).is_absolute(): + source_path = Path(ext_spec).expanduser().resolve() + if not source_path.exists(): + raise ValueError(f"Directory not found: {source_path}") + if not (source_path / "extension.yml").exists(): + raise ValueError(f"No extension.yml found in {source_path}") + manifest = manager.install_from_directory(source_path, speckit_version) + return f"{manifest.name} v{manifest.version} installed" + # --- URL --- # A malformed authority (e.g. an unterminated IPv6 bracket - # "https://[not-an-ip]/x.zip") makes urlparse raise ValueError. This + # "https://[not-an-ip]/x.zip") makes urlparse() raise ValueError eagerly + # on Python 3.14; on 3.11-3.13 urlparse() itself succeeds and the same + # authority only raises lazily when .hostname/.port is accessed. 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). + # display as a tracker error; without guarding both cases, 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) _ = parsed.hostname @@ -140,16 +159,6 @@ def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_ve raise ValueError(str(exc)) from exc return f"{manifest.name} v{manifest.version} installed" - # --- Local path --- - if ext_spec.startswith(("./", "../", "/", "~/", ".\\", "..\\")) or Path(ext_spec).is_absolute(): - source_path = Path(ext_spec).expanduser().resolve() - if not source_path.exists(): - raise ValueError(f"Directory not found: {source_path}") - if not (source_path / "extension.yml").exists(): - raise ValueError(f"No extension.yml found in {source_path}") - manifest = manager.install_from_directory(source_path, speckit_version) - return f"{manifest.name} v{manifest.version} installed" - # --- Bundled extension name or catalog ID --- bundled_path = _locate_bundled_extension(ext_spec) if bundled_path is not None: diff --git a/tests/test_init_output_markup.py b/tests/test_init_output_markup.py index 2b68a0460c..0d27a72116 100644 --- a/tests/test_init_output_markup.py +++ b/tests/test_init_output_markup.py @@ -198,3 +198,68 @@ def test_install_extension_during_init_reports_malformed_url_cleanly(tmp_path: P _install_extension_during_init( tmp_path, "https://[not-an-ip]/ext.zip", "1.0.0" ) + + +def test_install_extension_during_init_lazy_hostname_valueerror_reported_cleanly( + tmp_path: Path, monkeypatch +): + """Synthetic defensive coverage for Python 3.11-3.13's lazy validation. + + On those interpreters ``urlparse()`` itself succeeds for a malformed + bracketed authority; the ``ValueError`` only fires when ``.hostname`` is + read. This monkeypatches ``urlparse`` to return an object whose + ``.hostname`` raises lazily, exercising that path on any interpreter so + the guard isn't only proven on whichever Python happens to raise eagerly. + """ + import urllib.parse + + real_urlparse = urllib.parse.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(urllib.parse, "urlparse", _fake_urlparse) + + (tmp_path / ".specify").mkdir() + with pytest.raises(ValueError, match="Malformed extension URL"): + _install_extension_during_init( + tmp_path, "https://example.com/ext.zip", "1.0.0" + ) + + +@pytest.mark.skipif(os.name != "nt", reason="drive-letter/URL-scheme collision is Windows-only") +def test_install_extension_during_init_bracketed_windows_path_not_misreported_as_url( + tmp_path: Path, +): + """A bracketed absolute Windows path must be handled as a local path, + not misclassified as a malformed URL. + + ``urlparse("C://[my-ext]")`` parses with scheme ``"c"`` (a bare drive + letter looks like a URL scheme to urlparse) and a netloc of ``"[my-ext]"`` + (the doubled slash right after the drive letter is what triggers netloc + capture); on Python 3.14, ``urlparse()`` itself eagerly raises + ``ValueError`` for that bracketed authority. If URL parsing ran before + the local-path check, a real extension directory spec'd this way would + be misreported as "Malformed extension URL" instead of being looked up + on disk. The path doesn't need to exist for this: what matters is which + branch handles it -- local-path failure ("Directory not found") proves + it was never treated as a URL. + """ + drive = tmp_path.drive or "C:" + spec = f"{drive}//[nonexistent-bracketed-ext]" + + with pytest.raises(ValueError, match="Directory not found") as excinfo: + _install_extension_during_init(tmp_path, spec, "1.0.0") + + assert "Malformed extension URL" not in str(excinfo.value)