Skip to content
Merged
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
1 change: 1 addition & 0 deletions extensions/EXTENSION-PUBLISHING-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ A: Extensions should be free and open-source. Commercial support/services are al
"author": "string (required)",
"version": "string (required, semver)",
"download_url": "string (required, valid URL)",
"sha256": "string (optional, SHA-256 hex digest of the archive at download_url; verified before install)",
"repository": "string (required, valid URL)",
"homepage": "string (optional, valid URL)",
"documentation": "string (optional, valid URL)",
Expand Down
1 change: 1 addition & 0 deletions presets/PUBLISHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ Edit `presets/catalog.community.json` and add your preset.
"author": "Your Name",
"version": "1.0.0",
"download_url": "https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip",
"sha256": "OPTIONAL: SHA-256 hex digest of the archive above; verified before install",
"repository": "https://github.com/your-org/spec-kit-preset-your-preset",
"license": "MIT",
"requires": {
Expand Down
5 changes: 5 additions & 0 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from .._utils import dump_frontmatter, relative_extension_path_violation
from ..catalogs import CatalogEntry as BaseCatalogEntry
from ..catalogs import CatalogStackBase
from ..shared_infra import verify_archive_sha256

_FALLBACK_CORE_COMMAND_NAMES = frozenset(
{
Expand Down Expand Up @@ -2617,6 +2618,10 @@ def download_extension(
) as response:
zip_data = response.read()

verify_archive_sha256(
zip_data, ext_info.get("sha256"), extension_id, ExtensionError
)

zip_path.write_bytes(zip_data)
return zip_path

Expand Down
5 changes: 5 additions & 0 deletions src/specify_cli/presets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from .._init_options import is_ai_skills_enabled
from ..integrations.base import IntegrationBase
from .._utils import dump_frontmatter
from ..shared_infra import verify_archive_sha256


def _substitute_core_template(
Expand Down Expand Up @@ -2505,6 +2506,10 @@ def download_pack(
with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response:
zip_data = response.read()

verify_archive_sha256(
zip_data, pack_info.get("sha256"), pack_id, PresetError
)

zip_path.write_bytes(zip_data)
return zip_path

Expand Down
71 changes: 71 additions & 0 deletions src/specify_cli/shared_infra.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

from __future__ import annotations

import hashlib
import hmac
import logging
import os
import re
import tempfile
Expand All @@ -11,6 +14,74 @@
from .integrations.base import IntegrationBase
from .integrations.manifest import IntegrationManifest

logger = logging.getLogger(__name__)

# Matches a SHA-256 digest in its normalized form: exactly 64 hexadecimal
# characters. Callers lowercase the declared value before matching (see
# ``expected_hex = raw.lower()`` below), so an uppercase digest is accepted and
# normalized rather than rejected.
_SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$")


def verify_archive_sha256(
data: bytes,
expected: str | None,
name: str,
error_cls: type[Exception],
) -> None:
"""Verify downloaded archive bytes against a catalog-declared SHA-256.

Catalog entries may pin the expected digest of their release archive in a
``sha256`` field (optionally prefixed with ``"sha256:"``). When present, the
downloaded bytes must match before they are written to disk and installed,
so a corrupted or tampered archive is rejected even though the transport was
HTTPS. Entries without a declared digest are accepted unchanged, keeping the
check backwards compatible.

Args:
data: The raw downloaded archive bytes.
expected: The catalog-declared SHA-256 hex digest, or ``None``.
name: The extension/preset id, used in the error message.
error_cls: Exception type to raise on mismatch (e.g. ``ExtensionError``).

Raises:
error_cls: If ``expected`` is provided and is not a well-formed
SHA-256 hex digest, or does not match ``data``.
"""
# Skip only when no digest is declared at all (``None``). A declared but
# empty/blank value (e.g. ``sha256: ""``) is an authoring error, not an
# opt-out: let it fall through to the format check below so it is rejected
# rather than silently disabling verification.
if expected is None:
logger.debug(
"No sha256 declared for %r; archive integrity was not verified.",
name,
)
return
# Strip *only* a literal ``sha256:`` algorithm prefix (case-insensitive).
# Any other prefix is part of the value and must not be silently dropped,
# otherwise a malformed or wrong-algorithm digest (e.g. ``md5:...``) would
# be quietly accepted as if it were a valid SHA-256.
raw = str(expected).strip()
if raw[:7].lower() == "sha256:":
raw = raw[7:].strip()
expected_hex = raw.lower()
if not _SHA256_HEX_RE.match(expected_hex):
raise error_cls(
f"Invalid sha256 declared for {name!r}: expected 64 hexadecimal "
f"characters (optionally prefixed with 'sha256:'), got "
f"{expected!r}."
)
actual_hex = hashlib.sha256(data).hexdigest()
# Constant-time comparison: both sides are fixed-length hex digests, so use
# ``hmac.compare_digest`` to avoid leaking information through timing.
if not hmac.compare_digest(actual_hex, expected_hex):
raise error_cls(
f"Integrity check failed for {name!r}: the catalog declares "
f"sha256 {expected_hex}, but the downloaded archive is "
f"{actual_hex}. The archive may be corrupted or tampered with."
)


class SymlinkedSharedPathError(ValueError):
"""Raised when a shared infrastructure path or ancestor is a symlink.
Expand Down
83 changes: 83 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3716,6 +3716,89 @@ def fake_open(req, timeout=None):
assert captured[1].get_header("Authorization") == "Bearer ghp_testtoken"
assert captured[1].get_header("Accept") == "application/octet-stream"

def _make_zip_bytes(self):
"""Build a minimal valid extension ZIP in memory for download tests."""
import zipfile
import io

buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("extension.yml", "id: test-ext\nname: Test\nversion: 1.0.0\n")
return buf.getvalue()

def _mock_response(self, data):
"""Build a context-manager mock HTTP response returning ``data``."""
from unittest.mock import MagicMock

resp = MagicMock()
resp.read.return_value = data
# Configure the context-manager protocol explicitly so `with resp`
# yields `resp` itself, independent of how the protocol is invoked.
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
return resp

def test_download_extension_accepts_matching_sha256(self, temp_dir):
"""A catalog ``sha256`` that matches the archive is accepted."""
import hashlib
from unittest.mock import patch

catalog = self._make_catalog(temp_dir)
zip_bytes = self._make_zip_bytes()
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": "https://example.com/test-ext.zip",
"sha256": hashlib.sha256(zip_bytes).hexdigest(),
}

with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch.object(catalog, "_open_url", return_value=self._mock_response(zip_bytes)):
zip_path = catalog.download_extension("test-ext", target_dir=temp_dir)

assert zip_path.read_bytes() == zip_bytes

def test_download_extension_rejects_sha256_mismatch(self, temp_dir):
"""A catalog ``sha256`` that does not match the downloaded archive
aborts the install — a tampered or swapped archive is rejected.
"""
from unittest.mock import patch

catalog = self._make_catalog(temp_dir)
zip_bytes = self._make_zip_bytes()
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": "https://example.com/test-ext.zip",
"sha256": "0" * 64, # deliberately wrong
}

with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch.object(catalog, "_open_url", return_value=self._mock_response(zip_bytes)):
with pytest.raises(ExtensionError, match="[Ii]ntegrity"):
catalog.download_extension("test-ext", target_dir=temp_dir)

def test_download_extension_without_sha256_still_succeeds(self, temp_dir):
"""Entries without ``sha256`` keep working (backwards compatible)."""
from unittest.mock import patch

catalog = self._make_catalog(temp_dir)
zip_bytes = self._make_zip_bytes()
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": "https://example.com/test-ext.zip",
}

with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch.object(catalog, "_open_url", return_value=self._mock_response(zip_bytes)):
zip_path = catalog.download_extension("test-ext", target_dir=temp_dir)

assert zip_path.read_bytes() == zip_bytes

def test_download_extension_accepts_direct_github_rest_asset_url(self, temp_dir, monkeypatch):
"""download_extension can use a GitHub REST release asset URL directly."""
from unittest.mock import patch, MagicMock
Expand Down
84 changes: 84 additions & 0 deletions tests/test_presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -2019,6 +2019,90 @@ def fake_open(req, timeout=None):
assert captured[1].get_header("Authorization") == "Bearer ghp_testtoken"
assert captured[1].get_header("Accept") == "application/octet-stream"

def _pack_zip_and_response(self):
"""Build a minimal preset ZIP and a context-manager mock response."""
from unittest.mock import MagicMock
import io

zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w") as zf:
Comment thread
mnriem marked this conversation as resolved.
zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n")
zip_bytes = zip_buf.getvalue()

resp = MagicMock()
resp.read.return_value = zip_bytes
# Configure the context-manager protocol explicitly so `with resp`
# yields `resp` itself, independent of how the protocol is invoked.
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
return zip_bytes, resp

def test_download_pack_accepts_matching_sha256(self, project_dir):
"""A catalog ``sha256`` that matches the preset archive is accepted."""
import hashlib
from unittest.mock import patch

catalog = PresetCatalog(project_dir)
zip_bytes, resp = self._pack_zip_and_response()
pack_info = {
"id": "test-pack",
"name": "Test Pack",
"version": "1.0.0",
"download_url": "https://example.com/test-pack.zip",
"sha256": hashlib.sha256(zip_bytes).hexdigest(),
"_install_allowed": True,
}

with patch.object(catalog, "get_pack_info", return_value=pack_info), \
patch.object(catalog, "_open_url", return_value=resp):
zip_path = catalog.download_pack("test-pack", target_dir=project_dir)

assert zip_path.read_bytes() == zip_bytes

def test_download_pack_rejects_sha256_mismatch(self, project_dir):
"""A catalog ``sha256`` that does not match the archive aborts install."""
from unittest.mock import patch

catalog = PresetCatalog(project_dir)
_zip_bytes, resp = self._pack_zip_and_response()
pack_info = {
"id": "test-pack",
"name": "Test Pack",
"version": "1.0.0",
"download_url": "https://example.com/test-pack.zip",
"sha256": "0" * 64, # deliberately wrong
"_install_allowed": True,
}

with patch.object(catalog, "get_pack_info", return_value=pack_info), \
patch.object(catalog, "_open_url", return_value=resp):
with pytest.raises(PresetError, match="[Ii]ntegrity"):
catalog.download_pack("test-pack", target_dir=project_dir)

def test_download_pack_without_sha256_skips_verification(self, project_dir):
"""A catalog entry with no ``sha256`` keeps working: verification is
opt-in, so the backwards-compatible path (``pack_info.get("sha256")``
is ``None``) must download without aborting — mirrors the extensions
coverage so the helper never silently becomes mandatory for presets.
"""
from unittest.mock import patch

catalog = PresetCatalog(project_dir)
zip_bytes, resp = self._pack_zip_and_response()
pack_info = {
"id": "test-pack",
"name": "Test Pack",
"version": "1.0.0",
"download_url": "https://example.com/test-pack.zip",
"_install_allowed": True,
}

with patch.object(catalog, "get_pack_info", return_value=pack_info), \
patch.object(catalog, "_open_url", return_value=resp):
zip_path = catalog.download_pack("test-pack", target_dir=project_dir)

assert zip_path.read_bytes() == zip_bytes

def test_download_pack_accepts_direct_github_rest_asset_url(self, project_dir, monkeypatch):
"""download_pack can use a GitHub REST release asset URL directly."""
from unittest.mock import patch, MagicMock
Expand Down
Loading