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
53 changes: 30 additions & 23 deletions src/specify_cli/commands/bundle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -746,11 +746,16 @@ def _resolve_manifest_path(path: Path | None) -> Path:
def _download_manifest(resolved, *, offline: bool):
"""Resolve a bundle's manifest from its catalog ``download_url``.

Local/``file://`` URLs always work offline and may point at a ``.zip``
artifact, a bundle directory, or a ``bundle.yml`` (handled by
:func:`_local_manifest_source`). Remote ``https://`` URLs are fetched with
the shared authenticated, redirect-validated HTTP client, and only when not
``--offline``.
Catalog ``download_url``s are HTTPS-only (``http`` allowed for localhost),
matching the extensions/presets/workflows catalog systems. Remote URLs are
fetched with the shared authenticated, redirect-validated HTTP client, and
only when not ``--offline``.

Local and ``file://`` sources are intentionally not resolved here: to
install a bundle from disk, pass the path positionally
(``specify bundle install ./path/to/bundle.yml`` — a bundle directory or a
``.zip`` artifact also works), which :func:`_local_manifest_source` handles
before catalog resolution and which never touches ``download_url``.
"""
from urllib.parse import urlparse

Expand All @@ -763,26 +768,28 @@ def _download_manifest(resolved, *, offline: bool):
parsed = urlparse(url)
scheme = parsed.scheme.lower()

# On Windows an absolute path like ``C:\bundle.yml`` parses with a
# single-letter ``scheme``; treat it as a local file, not a URL scheme.
# ``file://`` URLs and bare filesystem paths (including Windows drive paths
# like ``C:\bundle.yml``, which urlparse reads as a single-letter scheme)
# are not valid catalog download URLs. Catalog URLs are HTTPS-only across
# every catalog system; installing from disk is done by passing the path
# positionally, which never reaches URL resolution. Give an actionable
# error rather than accepting a scheme the rest of the codebase rejects.
if scheme in ("", "file") or re.match(r"^[A-Za-z]:[\\/]", url):
local = Path(parsed.path if scheme == "file" else url)
manifest = _local_manifest_source(str(local))
if manifest is None:
raise BundlerError(f"Bundle manifest not found: {local}")
return manifest

if scheme in ("http", "https"):
if offline:
raise BundlerError(
f"Network access disabled; cannot download bundle '{resolved.entry.id}' "
f"from {url}."
)
return _download_remote_manifest(resolved.entry.id, url)
raise BundlerError(
f"Catalog entry '{resolved.entry.id}' has a local/file:// download_url "
f"({url}); catalog download URLs must be HTTPS (http for localhost). "
"To install a bundle from disk, pass the path directly: "
"'specify bundle install <path-to-bundle.yml | bundle-dir | .zip>'."
)

raise BundlerError(
f"Unsupported download_url scheme for bundle '{resolved.entry.id}': {url}"
)
if offline:
raise BundlerError(
f"Network access disabled; cannot download bundle '{resolved.entry.id}' "
f"from {url}."
)
# HTTPS-only (http for localhost) is enforced by _require_https inside
# _download_remote_manifest, matching the other catalog systems.
return _download_remote_manifest(resolved.entry.id, url)


def _require_https(label: str, url: str) -> None:
Expand Down
36 changes: 29 additions & 7 deletions tests/contract/test_bundle_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,21 +175,38 @@ def test_build_produces_artifact(project: Path):
assert len(artifacts) == 1


def test_info_expands_full_component_set(project: Path):
def _mock_manifest_download(monkeypatch, source_path: Path) -> None:
"""Mock the HTTPS manifest fetch to return a locally-authored manifest.

Catalog ``download_url``s are HTTPS-only, so ``info`` tests can no longer
point one at a local file. Patch ``_download_manifest`` to return the
manifest parsed from *source_path* (a bundle.yml or a .zip artifact),
exercising ``info``'s expansion without a network call.
"""
from specify_cli.commands.bundle import _local_manifest_source

monkeypatch.setattr(
"specify_cli.commands.bundle._download_manifest",
lambda resolved, *, offline: _local_manifest_source(str(source_path)),
)


def test_info_expands_full_component_set(project: Path, monkeypatch):
bundle_dir = project / "src-bundle"
bundle_dir.mkdir()
(bundle_dir / "bundle.yml").write_text(
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
)
catalog = project / "local-catalog.json"
entry = catalog_entry_dict(
"demo-bundle", download_url=str(bundle_dir / "bundle.yml")
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
)
write_catalog_file(catalog, {"demo-bundle": entry})
added = runner.invoke(
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
)
assert added.exit_code == 0, added.output
_mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml")

result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
assert result.exit_code == 0, result.output
Expand All @@ -207,7 +224,7 @@ def test_info_expands_full_component_set(project: Path):
assert "Trust" in text.output


def test_info_expands_discovery_only_bundle(project: Path):
def test_info_expands_discovery_only_bundle(project: Path, monkeypatch):
# Discovery-only bundles must still be fully inspectable via `info`;
# only `install` is refused for them.
bundle_dir = project / "disc-bundle"
Expand All @@ -217,7 +234,7 @@ def test_info_expands_discovery_only_bundle(project: Path):
)
catalog = project / "disc-catalog.json"
entry = catalog_entry_dict(
"demo-bundle", download_url=str(bundle_dir / "bundle.yml")
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
)
write_catalog_file(catalog, {"demo-bundle": entry})
config = {
Expand All @@ -230,15 +247,17 @@ def test_info_expands_discovery_only_bundle(project: Path):
(project / ".specify" / "bundle-catalogs.yml").write_text(
yaml.safe_dump(config), encoding="utf-8"
)
_mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml")
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
components = {(c["kind"], c["id"]) for c in payload["components"]}
assert ("extensions", "ext-a") in components


def test_info_resolves_local_zip_download_url(project: Path):
# A local .zip artifact as download_url is extracted to read bundle.yml.
def test_info_expands_zip_sourced_bundle(project: Path, monkeypatch):
# A .zip artifact is extracted to read bundle.yml; info expands it. (The
# download itself is HTTPS-only now and mocked here — see contract note.)
bundle_dir = project / "zip-src"
bundle_dir.mkdir()
(bundle_dir / "bundle.yml").write_text(
Expand All @@ -249,12 +268,15 @@ def test_info_resolves_local_zip_download_url(project: Path):
catalog = project / "zip-catalog.json"
write_catalog_file(
catalog,
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=str(artifact))},
{"demo-bundle": catalog_entry_dict(
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
)},
)
added = runner.invoke(
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
)
assert added.exit_code == 0, added.output
_mock_manifest_download(monkeypatch, artifact)
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
Expand Down
42 changes: 42 additions & 0 deletions tests/integration/test_bundler_local_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,45 @@ def test_install_bundled_extension_from_zip_offline(tmp_path: Path):
assert not ExtensionManager(project).registry.is_installed("agent-context")
finally:
os.chdir(previous)


def test_download_manifest_rejects_file_url(tmp_path: Path):
"""A catalog ``file://`` download_url is rejected — catalog URLs are
HTTPS-only, matching extensions/presets/workflows. Disk installs go through
the positional path (see the local-source tests above), not download_url.
"""
from types import SimpleNamespace

from specify_cli.commands.bundle import _download_manifest

manifest_path = write_manifest(tmp_path / "my bundles")
resolved = SimpleNamespace(
entry=SimpleNamespace(id="demo-bundle", download_url=manifest_path.as_uri())
)

with pytest.raises(BundlerError, match="bundle install"):
_download_manifest(resolved, offline=True)


def test_download_manifest_rejects_bare_path(tmp_path: Path):
"""A bare filesystem path download_url is likewise rejected."""
from types import SimpleNamespace

from specify_cli.commands.bundle import _download_manifest

manifest_path = write_manifest(tmp_path / "plain")
resolved = SimpleNamespace(
entry=SimpleNamespace(id="demo-bundle", download_url=str(manifest_path))
)

with pytest.raises(BundlerError, match="bundle install"):
_download_manifest(resolved, offline=True)


def test_local_install_still_resolves_via_positional_path(tmp_path: Path):
"""The supported local route — a positional path, not a download_url —
still resolves the manifest via _local_manifest_source."""
manifest_path = write_manifest(tmp_path / "my bundles")
manifest = _local_manifest_source(str(manifest_path))
assert manifest is not None
assert manifest.bundle.id == "demo-bundle"