Skip to content
Open
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
43 changes: 32 additions & 11 deletions src/specify_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Moved the local-path check before URL parsing entirely, rather than trying to special-case bracketed authorities: _install_extension_during_init now only calls urlparse()/probes .hostname/.port once the spec has already failed the local-path test. C://[my-ext] (and any other absolute/relative-path-shaped spec) never reaches urlparse at all, so it can't be misreported as a malformed URL on any interpreter -- 3.14's eager parse-time raise included, which is actually where this specific repro fires (not just the lazy .hostname path). Added a regression test using the same drive-letter+bracket construction, asserting the failure is "Directory not found" (local-path branch) not "Malformed extension URL". Confirmed via test-the-test that it fails against the prior commit and passes now (ad762fe).

_ = 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:
Expand Down
91 changes: 90 additions & 1 deletion tests/test_init_output_markup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <bad-url>` 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)