diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 4af9427bfa..2ff2977b76 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -117,18 +117,13 @@ def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_ve manager = ExtensionManager(project_path) - # --- URL --- - parsed = urlparse(ext_spec) - if parsed.scheme in ("http", "https"): - try: - manifest = install_extension_from_url( - manager, project_path, ext_spec, speckit_version - ) - except ExtensionError as exc: - raise ValueError(str(exc)) from exc - return f"{manifest.name} v{manifest.version} installed" - # --- 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(): @@ -138,6 +133,32 @@ def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_ve 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 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 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 + _ = parsed.port + 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( + manager, project_path, ext_spec, speckit_version + ) + except ExtensionError as exc: + raise ValueError(str(exc)) from exc + 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 54576fb33f..0d27a72116 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,89 @@ 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" + ) + + +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)