harden: secure extension and preset archive downloads (#3141)

* harden: secure extension and preset archive downloads

Adopt the shared download-security primitives from #3140 across extension and
preset catalog, package, direct-URL, and ZIP-install flows:

- bound catalog, package, and inline manifest reads;
- verify catalog SHA-256 values when present;
- replace path-only extraction with bounded traversal/symlink-safe extraction;
- validate malformed hosts and ports before opening download URLs;
- handle normalized trailing-backslash directory entries consistently.

Redirect enforcement and checksum verification remain owned by the shared
helpers already on main; this commit wires them into extension and preset
behavior.

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* harden: close archive and catalog download edge cases

Preflight ZIP central directories before ZipFile allocates them, bound both
declared and actual payload sizes, and reject ambiguous or non-portable archive
paths before extraction.

Keep extension update manifest selection consistent with extraction, reject
unsafe catalog-derived output filenames and malformed URL types, and escape
untrusted values in download errors.

Add regression coverage for parser differentials, collisions, platform-specific
filenames, bounded call sites, and failure ordering.

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* harden: address download security review feedback

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* harden: close ZIP preflight review gaps

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* fix: harden extension update preflight and rollback

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* fix: harden extension update rollback

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
This commit is contained in:
Pascal THUET
2026-07-28 14:52:07 +02:00
committed by GitHub
parent 0117a7b977
commit 118062eac4
24 changed files with 6637 additions and 434 deletions

View File

@@ -1,10 +1,19 @@
"""Helpers for bounded HTTP downloads."""
"""Helpers for bounded downloads and archive extraction."""
from __future__ import annotations
import io
import re
import socket
import stat
import struct
import unicodedata
import zipfile
from collections.abc import Iterator
from contextlib import ExitStack, contextmanager
from ipaddress import IPv4Address, IPv6Address, ip_address
from itertools import pairwise
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import NoReturn, TypeVar
from urllib.parse import ParseResult, urlparse
@@ -12,17 +21,52 @@ from urllib.parse import ParseResult, urlparse
ErrorT = TypeVar("ErrorT", bound=Exception)
MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024
MAX_ZIP_ENTRIES = 512
MAX_ZIP_MEMBER_BYTES = 10 * 1024 * 1024
MAX_ZIP_TOTAL_BYTES = 50 * 1024 * 1024
MAX_ZIP_PATH_BYTES = 4096
MAX_ZIP_COMPONENT_BYTES = 255
# ``ZipFile`` reads this whole structure into memory. Four MiB leaves roughly
# 8 KiB of filename/extra/comment metadata for each of the 512 allowed entries.
MAX_ZIP_CENTRAL_DIRECTORY_BYTES = 4 * 1024 * 1024
READ_CHUNK_SIZE = 64 * 1024
# Tighter ceiling for responses that are read fully into memory and parsed as
# Tighter ceilings for responses that are read fully into memory and parsed as
# JSON. The 50 MiB MAX_DOWNLOAD_BYTES default is sized for archive/payload
# downloads; JSON metadata responses are far smaller, so capping them close to
# their real size shrinks the memory-DoS surface and keeps the "too large"
# error reachable (rather than only triggering on tens of MiB). Pass it
# downloads; JSON responses are far smaller, so capping them close to their real
# size shrinks the memory-DoS surface and keeps the "too large" error reachable
# (rather than only triggering on tens of MiB). Pass the matching constant
# explicitly at each JSON call site so the intended bound is pinned there.
# METADATA covers fixed-shape single-object responses (an OAuth token, one
# release's metadata): a few KiB in practice, 1 MiB is already generous.
# * METADATA - fixed-shape single-object responses (an OAuth token, one
# release's metadata): a few KiB in practice, 1 MiB is already generous.
# * CATALOG - listings that grow with the number of published items. The
# largest bundled catalog is ~130 KiB today, so 8 MiB leaves ~60x headroom
# for growth while staying well under the download ceiling.
MAX_JSON_METADATA_BYTES = 1 * 1024 * 1024
MAX_JSON_CATALOG_BYTES = 8 * 1024 * 1024
_WINDOWS_INVALID_FILENAME_CHARS = frozenset('<>:"|?*')
_WINDOWS_RESERVED_FILENAME = re.compile(
r"^(?:con|prn|aux|nul|conin\$|conout\$|"
r"com[1-9\u00b9\u00b2\u00b3]|lpt[1-9\u00b9\u00b2\u00b3])$",
re.IGNORECASE,
)
_ZIP_EOCD = struct.Struct("<4s4H2LH")
_ZIP_EOCD_SIGNATURE = b"PK\x05\x06"
_ZIP64_LOCATOR_SIGNATURE = b"PK\x06\x07"
_ZIP_CENTRAL_HEADER_SIZE = 46
_ZIP_CENTRAL_SIGNATURE = b"PK\x01\x02"
_ZIP_LOCAL_HEADER_SIZE = 30
_ZIP_LOCAL_SIGNATURE = b"PK\x03\x04"
_ZIP_EXTRA_HEADER = struct.Struct("<HH")
_ZIP64_EXTRA_FIELD_ID = 0x0001
_ZIP64_MIN_EXTRACT_VERSION = 45
_ZIP_UINT16_MAX = (1 << 16) - 1
_ZIP_UINT32_MAX = (1 << 32) - 1
_ZIP_MAX_COMMENT_BYTES = (1 << 16) - 1
_BOUNDED_ZIP_COMPRESSION_METHODS = frozenset(
(zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED)
)
def _ip_address_without_scope(
@@ -179,6 +223,41 @@ def _raise(error_type: type[ErrorT], message: str) -> NoReturn:
raise error_type(message)
def _raise_from(error_type: type[ErrorT], message: str, exc: Exception) -> NoReturn:
raise error_type(message) from exc
class _ReadLimitExceeded(Exception):
"""Internal signal used to keep domain-specific errors at call sites."""
def _validate_non_negative_int(value: int, name: str) -> None:
if isinstance(value, bool) or not isinstance(value, int):
raise TypeError(f"{name} must be an integer")
if value < 0:
raise ValueError(f"{name} must be non-negative")
def _validate_max_bytes(max_bytes: int) -> None:
_validate_non_negative_int(max_bytes, "max_bytes")
def _read_limited(response, max_bytes: int) -> bytes:
"""Read a stream with bounded requests and without retaining fragments."""
output = io.BytesIO()
total = 0
limit = max_bytes + 1
while total < limit:
chunk = response.read(min(READ_CHUNK_SIZE, limit - total))
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise _ReadLimitExceeded
output.write(chunk)
return output.getvalue()
def read_response_limited(
response,
*,
@@ -199,20 +278,619 @@ def read_response_limited(
explicit value so the intended bound is pinned at the call site rather than
tracking changes to the shared default.
"""
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int):
raise TypeError("max_bytes must be an integer")
if max_bytes < 0:
raise ValueError("max_bytes must be non-negative")
_validate_max_bytes(max_bytes)
try:
return _read_limited(response, max_bytes)
except _ReadLimitExceeded:
_raise(error_type, f"{label!r} exceeds maximum size of {max_bytes} bytes")
output = io.BytesIO()
total = 0
limit = max_bytes + 1
while total < limit:
chunk = response.read(min(READ_CHUNK_SIZE, limit - total))
if not chunk:
break
total += len(chunk)
if total > max_bytes:
_raise(error_type, f"{label} exceeds maximum size of {max_bytes} bytes")
output.write(chunk)
return output.getvalue()
def build_safe_download_path(
target_dir: Path,
identifier: object,
version: object,
*,
error_type: type[ErrorT] = ValueError,
label: str = "archive",
) -> Path:
"""Build a portable single-component archive path inside *target_dir*."""
if not isinstance(identifier, str) or not isinstance(version, str):
_raise(
error_type,
f"Unsafe {label} download filename derived from "
f"{identifier!r} and {version!r}",
)
filename = f"{identifier}-{version}.zip"
try:
filename_too_long = (
len(filename.encode("utf-8")) > MAX_ZIP_COMPONENT_BYTES
)
except UnicodeEncodeError:
filename_too_long = True
posix_path = PurePosixPath(filename)
windows_path = PureWindowsPath(filename)
if (
filename_too_long
or posix_path.name != filename
or windows_path.name != filename
or any(unicodedata.category(character) == "Cc" for character in filename)
or any(
character in _WINDOWS_INVALID_FILENAME_CHARS
for character in filename
)
or filename.endswith((" ", "."))
):
_raise(
error_type,
f"Unsafe {label} download filename derived from "
f"{identifier!r} and {version!r}",
)
return Path(target_dir) / filename
def read_zip_member_limited(
zf: zipfile.ZipFile,
name: str,
*,
max_bytes: int = MAX_ZIP_MEMBER_BYTES,
error_type: type[ErrorT] = ValueError,
label: str | None = None,
) -> bytes:
"""Read a single ZIP member into memory under a hard size cap.
Reading a member with ``zf.open(name).read()`` is unbounded: a crafted
archive can declare a tiny ``file_size`` yet decompress to many gigabytes (a
"zip bomb"), exhausting memory before the caller ever inspects the data.
This rejects members whose *declared* size already exceeds *max_bytes* and,
to defend against headers that lie, also reads in bounded chunks and stops
one byte past the limit.
Use this for any inline manifest/metadata read that happens *before*
:func:`safe_extract_zip` (which already enforces the same per-member bound
during extraction); a raw ``zf.open(...).read()`` bypasses that protection.
"""
_validate_max_bytes(max_bytes)
member_label = label or name
try:
info = zf.getinfo(name)
except KeyError as exc:
_raise_from(error_type, f"ZIP member not found: {name!r}", exc)
if info.file_size > max_bytes:
_raise(
error_type,
f"ZIP member {member_label!r} exceeds maximum size of {max_bytes} bytes",
)
try:
with zf.open(name, "r") as source:
return _read_limited(source, max_bytes)
except _ReadLimitExceeded:
_raise(
error_type,
f"ZIP member {member_label!r} exceeds maximum size of {max_bytes} bytes",
)
except Exception as exc:
_raise_from(
error_type,
f"Failed to read ZIP member {member_label!r}: {exc!r}",
exc,
)
def normalize_zip_member_name(
name: str,
*,
error_type: type[ErrorT] = ValueError,
) -> str:
"""Return a normalized, portable ZIP member name or raise if unsafe."""
if "\x00" in name:
_raise(error_type, f"Unsafe path in ZIP archive: {name!r}")
normalized = name.replace("\\", "/")
try:
encoded_name = normalized.encode("utf-8")
except UnicodeEncodeError:
_raise(error_type, f"Unsafe path in ZIP archive: {name!r}")
if len(encoded_name) > MAX_ZIP_PATH_BYTES:
_raise(
error_type,
f"Unsafe path in ZIP archive: {name!r} "
"(not portable across supported filesystems)",
)
path = PurePosixPath(normalized)
raw_parts = normalized.split("/")
# Strip a single trailing empty segment, i.e. the one-slash directory
# marker that legitimate ZIPs use ("mydir/", "mydir/subdir/"). Anything
# else that produces an empty segment - consecutive slashes ("a//b") or a
# second trailing slash - is left in place and rejected below as malformed.
if raw_parts and raw_parts[-1] == "":
raw_parts = raw_parts[:-1]
has_windows_drive = re.match(r"^[A-Za-z]:", normalized) is not None
if (
not raw_parts
or path.is_absolute()
or has_windows_drive
or any(part in {"", ".", ".."} for part in raw_parts)
):
_raise(
error_type,
f"Unsafe path in ZIP archive: {name!r} (potential path traversal)",
)
for part in raw_parts:
reserved_stem = part.partition(".")[0].partition(":")[0].rstrip(" ")
if (
len(part.encode("utf-8")) > MAX_ZIP_COMPONENT_BYTES
or any(
unicodedata.category(character) == "Cc"
for character in part
)
or any(character in _WINDOWS_INVALID_FILENAME_CHARS for character in part)
or part.startswith(" ")
or part.endswith((" ", "."))
or _WINDOWS_RESERVED_FILENAME.fullmatch(reserved_stem)
):
_raise(
error_type,
f"Unsafe path in ZIP archive: {name!r} "
"(not portable across supported filesystems)",
)
return normalized
def portable_zip_path_key(name: str) -> tuple[str, ...]:
"""Return a comparison key for filesystems with case/Unicode folding."""
normalized_name = name.replace("\\", "/")
return tuple(
unicodedata.normalize("NFC", part.casefold())
for part in normalized_name.removesuffix("/").split("/")
)
def _raise_zip64(error_type: type[ErrorT]) -> NoReturn:
_raise(
error_type,
"ZIP64 archives are not supported by the bounded extractor",
)
def _preflight_zip_entry_features(
extract_version: int,
compression_method: int,
*,
error_type: type[ErrorT],
) -> None:
"""Enforce the formats whose output can be bounded by ``ZipExtFile``.
Python's BZIP2 and LZMA ``ZipExtFile`` paths do not pass the requested
output length to the decompressor; only STORED and DEFLATED preserve this
module's hard memory bound. APPNOTE assigns extract version 4.5 to ZIP64
size extensions. Because this field declares the minimum extractor feature
level, reject 4.5 and every newer level for the supported methods,
independently of the usual size sentinels and extra field.
"""
if compression_method not in _BOUNDED_ZIP_COMPRESSION_METHODS:
_raise(
error_type,
f"Unsupported ZIP compression method {compression_method}; "
"the bounded extractor supports only STORED and DEFLATED",
)
if extract_version >= _ZIP64_MIN_EXTRACT_VERSION:
_raise(
error_type,
"ZIP64 or newer ZIP features requiring extractor version 4.5 or "
"newer are not supported by the bounded extractor",
)
def _reject_zip64_extra_fields(
extra: bytes,
zip_path: Path,
*,
error_type: type[ErrorT],
) -> None:
"""Reject ZIP64 extra fields and malformed complete extra records."""
offset = 0
while offset + _ZIP_EXTRA_HEADER.size <= len(extra):
field_id, field_size = _ZIP_EXTRA_HEADER.unpack_from(extra, offset)
field_end = offset + _ZIP_EXTRA_HEADER.size + field_size
if field_id == _ZIP64_EXTRA_FIELD_ID:
_raise_zip64(error_type)
if field_end > len(extra):
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
offset = field_end
def _preflight_zip_local_header(
archive_file,
zip_path: Path,
*,
error_type: type[ErrorT],
archive_prefix_size: int,
central_directory_start: int,
local_header_offset: int,
) -> None:
"""Reject local-entry ZIP64 indicators before ``ZipFile`` is constructed."""
physical_offset = archive_prefix_size + local_header_offset
if (
physical_offset < archive_prefix_size
or physical_offset + _ZIP_LOCAL_HEADER_SIZE > central_directory_start
):
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
archive_file.seek(physical_offset)
header = archive_file.read(_ZIP_LOCAL_HEADER_SIZE)
if (
len(header) != _ZIP_LOCAL_HEADER_SIZE
or header[:4] != _ZIP_LOCAL_SIGNATURE
):
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
extract_version = struct.unpack_from("<H", header, 4)[0]
compression_method = struct.unpack_from("<H", header, 8)[0]
_preflight_zip_entry_features(
extract_version,
compression_method,
error_type=error_type,
)
compressed_size, uncompressed_size = struct.unpack_from("<LL", header, 18)
if (
compressed_size == _ZIP_UINT32_MAX
or uncompressed_size == _ZIP_UINT32_MAX
):
_raise_zip64(error_type)
filename_size, extra_size = struct.unpack_from("<HH", header, 26)
extra_offset = physical_offset + _ZIP_LOCAL_HEADER_SIZE + filename_size
if extra_offset + extra_size > central_directory_start:
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
archive_file.seek(extra_offset)
extra = archive_file.read(extra_size)
if len(extra) != extra_size:
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
_reject_zip64_extra_fields(extra, zip_path, error_type=error_type)
def _preflight_zip_central_directory(
archive_file,
zip_path: Path,
*,
error_type: type[ErrorT],
max_entries: int,
) -> None:
"""Bound and count the central directory before ``ZipFile`` materializes it."""
archive_file.seek(0, 2)
file_size = archive_file.tell()
tail_size = min(file_size, _ZIP_EOCD.size + _ZIP_MAX_COMMENT_BYTES)
archive_file.seek(file_size - tail_size)
tail = archive_file.read(tail_size)
# ZipFile selects the last EOCD signature in the search window. Inspect
# exactly that record too: falling back to an earlier signature would let
# the preflight validate one central directory while ZipFile materializes
# another.
eocd_index = tail.rfind(_ZIP_EOCD_SIGNATURE)
if eocd_index < 0 or eocd_index + _ZIP_EOCD.size > len(tail):
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
eocd = _ZIP_EOCD.unpack_from(tail, eocd_index)
comment_size = eocd[-1]
if eocd_index + _ZIP_EOCD.size + comment_size != len(tail):
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
eocd_offset = file_size - len(tail) + eocd_index
if eocd_offset >= 20:
archive_file.seek(eocd_offset - 20)
if archive_file.read(4) == _ZIP64_LOCATOR_SIGNATURE:
_raise_zip64(error_type)
(
_signature,
disk_number,
central_directory_disk,
entries_on_disk,
declared_entries,
central_directory_size,
central_directory_offset,
_comment_size,
) = eocd
if (
disk_number != 0
or central_directory_disk != 0
or entries_on_disk != declared_entries
):
_raise(error_type, "Multi-disk ZIP archives are not supported")
if (
declared_entries == _ZIP_UINT16_MAX
or central_directory_size == _ZIP_UINT32_MAX
or central_directory_offset == _ZIP_UINT32_MAX
):
_raise_zip64(error_type)
if declared_entries > max_entries:
_raise(
error_type,
f"ZIP archive contains too many entries "
f"({declared_entries} > {max_entries})",
)
if central_directory_size > MAX_ZIP_CENTRAL_DIRECTORY_BYTES:
_raise(
error_type,
f"ZIP central directory exceeds maximum size of "
f"{MAX_ZIP_CENTRAL_DIRECTORY_BYTES} bytes",
)
central_directory_start = eocd_offset - central_directory_size
if (
central_directory_start < 0
or central_directory_offset > central_directory_start
):
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
archive_prefix_size = central_directory_start - central_directory_offset
consumed = 0
actual_entries = 0
local_header_offsets: list[int] = []
while consumed < central_directory_size:
archive_file.seek(central_directory_start + consumed)
remaining = central_directory_size - consumed
if remaining < _ZIP_CENTRAL_HEADER_SIZE:
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
header = archive_file.read(_ZIP_CENTRAL_HEADER_SIZE)
if (
len(header) != _ZIP_CENTRAL_HEADER_SIZE
or header[:4] != _ZIP_CENTRAL_SIGNATURE
):
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
extract_version = struct.unpack_from("<H", header, 6)[0]
compression_method = struct.unpack_from("<H", header, 10)[0]
_preflight_zip_entry_features(
extract_version,
compression_method,
error_type=error_type,
)
compressed_size, uncompressed_size = struct.unpack_from("<LL", header, 20)
disk_number_start = struct.unpack_from("<H", header, 34)[0]
local_header_offset = struct.unpack_from("<L", header, 42)[0]
if (
compressed_size == _ZIP_UINT32_MAX
or uncompressed_size == _ZIP_UINT32_MAX
or local_header_offset == _ZIP_UINT32_MAX
or disk_number_start == _ZIP_UINT16_MAX
):
_raise_zip64(error_type)
if disk_number_start != 0:
_raise(error_type, "Multi-disk ZIP archives are not supported")
filename_size, extra_size, comment_size = struct.unpack_from(
"<HHH", header, 28
)
variable_size = filename_size + extra_size + comment_size
record_size = _ZIP_CENTRAL_HEADER_SIZE + variable_size
if record_size > remaining:
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
variable_data = archive_file.read(variable_size)
if len(variable_data) != variable_size:
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
extra = variable_data[filename_size : filename_size + extra_size]
_reject_zip64_extra_fields(extra, zip_path, error_type=error_type)
local_header_offsets.append(local_header_offset)
consumed += record_size
actual_entries += 1
if actual_entries > max_entries:
_raise(
error_type,
f"ZIP archive contains too many entries "
f"({actual_entries} > {max_entries})",
)
if actual_entries != declared_entries:
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
for local_header_offset in local_header_offsets:
_preflight_zip_local_header(
archive_file,
zip_path,
error_type=error_type,
archive_prefix_size=archive_prefix_size,
central_directory_start=central_directory_start,
local_header_offset=local_header_offset,
)
@contextmanager
def open_zip_bounded(
zip_path: Path,
*,
error_type: type[ErrorT] = ValueError,
max_entries: int = MAX_ZIP_ENTRIES,
) -> Iterator[zipfile.ZipFile]:
"""Open an untrusted ZIP after a bounded-memory header preflight."""
_validate_non_negative_int(max_entries, "max_entries")
zip_path = Path(zip_path)
with ExitStack() as stack:
try:
archive_file = stack.enter_context(zip_path.open("rb"))
except OSError as exc:
_raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
try:
_preflight_zip_central_directory(
archive_file,
zip_path,
error_type=error_type,
max_entries=max_entries,
)
except OSError as exc:
_raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
try:
archive_file.seek(0)
zf = stack.enter_context(zipfile.ZipFile(archive_file, "r"))
except Exception as exc:
_raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
yield zf
def safe_extract_zip(
zip_path: Path,
target_dir: Path,
*,
error_type: type[ErrorT] = ValueError,
max_entries: int = MAX_ZIP_ENTRIES,
max_member_bytes: int = MAX_ZIP_MEMBER_BYTES,
max_total_bytes: int = MAX_ZIP_TOTAL_BYTES,
) -> None:
"""Extract a ZIP archive after path, symlink, and size validation."""
_validate_non_negative_int(max_member_bytes, "max_member_bytes")
_validate_non_negative_int(max_total_bytes, "max_total_bytes")
try:
target_root = target_dir.resolve()
except OSError as exc:
_raise_from(error_type, f"Invalid ZIP extraction target: {target_dir}", exc)
with open_zip_bounded(
zip_path,
error_type=error_type,
max_entries=max_entries,
) as zf:
try:
members = zf.infolist()
except zipfile.BadZipFile as exc:
_raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
if len(members) > max_entries:
_raise(
error_type,
f"ZIP archive contains too many entries ({len(members)} > {max_entries})",
)
normalized_members: list[tuple[zipfile.ZipInfo, str, bool]] = []
validated_paths: dict[tuple[str, ...], tuple[str, bool]] = {}
total_size = 0
for member in members:
normalized_name = normalize_zip_member_name(
member.filename,
error_type=error_type,
)
is_dir = member.is_dir() or normalized_name.endswith("/")
path_key = portable_zip_path_key(normalized_name)
existing = validated_paths.get(path_key)
if existing is not None:
_raise(
error_type,
f"Conflicting path in ZIP archive: {member.filename} conflicts "
f"with {existing[0]}",
)
validated_paths[path_key] = (member.filename, is_dir)
mode = member.external_attr >> 16
if stat.S_ISLNK(mode):
_raise(error_type, f"Unsafe symlink in ZIP archive: {member.filename}")
member_path = (target_dir / normalized_name).resolve()
try:
member_path.relative_to(target_root)
except ValueError:
_raise(
error_type,
f"Unsafe path in ZIP archive: {member.filename} "
"(potential path traversal)",
)
if not is_dir:
if member.file_size > max_member_bytes:
_raise(
error_type,
f"ZIP member {member.filename} exceeds maximum size "
f"of {max_member_bytes} bytes",
)
total_size += member.file_size
if total_size > max_total_bytes:
_raise(
error_type,
f"ZIP archive exceeds maximum uncompressed size "
f"of {max_total_bytes} bytes",
)
normalized_members.append((member, normalized_name, is_dir))
# Tuple sorting places every path immediately before its descendants.
# One adjacent comparison per entry detects file/directory conflicts
# without repeatedly rebuilding every path prefix.
for (
(path_key, (original, is_dir)),
(next_key, (next_original, _next_is_dir)),
) in pairwise(sorted(validated_paths.items())):
if (
not is_dir
and len(next_key) > len(path_key)
and next_key[: len(path_key)] == path_key
):
_raise(
error_type,
f"Conflicting path in ZIP archive: {original} conflicts "
f"with {next_original}",
)
# The loop above bounds the *declared* total via member.file_size, but a
# crafted archive can understate those headers. Mirror the per-member
# guard below with a cumulative count of the bytes actually written so
# the total-size bound holds even when the headers lie.
total_written = 0
for member, normalized_name, is_dir in normalized_members:
member_path = target_dir / normalized_name
if is_dir:
try:
member_path.mkdir(parents=True, exist_ok=True)
except OSError as exc:
_raise_from(
error_type,
f"Failed to create ZIP directory {member.filename}: {exc}",
exc,
)
continue
try:
member_path.parent.mkdir(parents=True, exist_ok=True)
except OSError as exc:
_raise_from(
error_type,
f"Failed to create parent directory for ZIP member {member.filename}: {exc}",
exc,
)
written = 0
# Raised outside the try below: if error_type subclasses OSError or
# RuntimeError, raising inside would re-wrap the limit error as
# "Failed to extract" and lose the size-bound message.
limit_error: str | None = None
try:
with zf.open(member, "r") as source, member_path.open("wb") as dest:
while True:
chunk = source.read(READ_CHUNK_SIZE)
if not chunk:
break
written += len(chunk)
if written > max_member_bytes:
limit_error = (
f"ZIP member {member.filename} exceeds maximum size "
f"of {max_member_bytes} bytes"
)
break
total_written += len(chunk)
if total_written > max_total_bytes:
limit_error = (
f"ZIP archive exceeds maximum uncompressed size "
f"of {max_total_bytes} bytes"
)
break
dest.write(chunk)
except Exception as exc:
_raise_from(
error_type,
f"Failed to extract ZIP member {member.filename}: {exc}",
exc,
)
if limit_error is not None:
_raise(error_type, limit_error)

View File

@@ -12,6 +12,7 @@ import yaml
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import Any
from ._console import console
from ._download_security import normalize_zip_member_name
CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude"
CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude"
@@ -27,19 +28,22 @@ def relative_extension_path_violation(value: Any) -> str | None:
``None`` when it is an acceptable relative path within the extension
directory.
Policy: the value must be a non-empty string with no leading/trailing
whitespace, no absolute/anchored form, and no ``..`` traversal. The value is
Policy: the value must be a non-empty, portable file path with no
leading/trailing whitespace, absolute/anchored form, ``..`` traversal,
platform-reserved component, or directory-only suffix. The value is
evaluated under both POSIX and Windows path semantics because a native
``Path`` is OS-dependent (a ``PurePosixPath`` on POSIX does not interpret
Windows drive/UNC forms, and ``C:foo`` is anchored but not ``is_absolute()``
yet resolves against the CWD on its drive). Rejecting any non-empty anchor
covers POSIX-absolute (``/abs``), Windows drive-relative (``C:foo``), Windows
absolute (``C:\\foo``), and UNC/rooted forms.
Windows drive/UNC forms, and ``C:foo`` is anchored but not
``is_absolute()`` yet resolves against the CWD on its drive). Rejecting any
non-empty anchor covers POSIX-absolute (``/abs``), Windows drive-relative
(``C:foo``), Windows absolute (``C:\\foo``), and UNC/rooted forms.
"""
if not isinstance(value, str) or not value:
return "must be a non-empty string"
if value.strip() != value:
return "must not have leading or trailing whitespace"
if "\\" in value:
return "must use forward slashes as path separators"
posix_path = PurePosixPath(value)
win_path = PureWindowsPath(value)
if (
@@ -52,6 +56,15 @@ def relative_extension_path_violation(value: Any) -> str | None:
"must be a relative path within the extension directory "
"(no absolute paths, drive letters, or '..' segments)"
)
if value.endswith(("/", "\\")):
return "must name a file or command, not a directory"
try:
normalize_zip_member_name(value)
except ValueError:
return (
"must use portable path components "
"(no reserved names or platform-invalid characters)"
)
return None

View File

@@ -675,6 +675,23 @@ class CommandRegistrar:
cmd_name = cmd_info["name"]
aliases = cmd_info.get("aliases", [])
cmd_file = cmd_info["file"]
name_reason = relative_extension_path_violation(cmd_name)
if name_reason:
raise ValueError(
f"Invalid command name {cmd_name!r}: {name_reason}"
)
if aliases is None:
aliases = []
if not isinstance(aliases, list):
raise ValueError(
f"Aliases for command {cmd_name!r} must be a list"
)
for alias in aliases:
alias_reason = relative_extension_path_violation(alias)
if alias_reason:
raise ValueError(
f"Invalid command alias {alias!r}: {alias_reason}"
)
# Guard against path traversal using the single shared policy in
# relative_extension_path_violation(), so the runtime guard stays
@@ -957,10 +974,16 @@ class CommandRegistrar:
project_root: Path to project root
cmd_name: Command name (e.g. 'speckit.my-ext.example')
"""
name_reason = relative_extension_path_violation(cmd_name)
if name_reason:
raise ValueError(
f"Invalid Copilot prompt name {cmd_name!r}: {name_reason}"
)
prompts_dir = project_root / ".github" / "prompts"
prompts_dir.mkdir(parents=True, exist_ok=True)
prompt_file = prompts_dir / f"{cmd_name}.prompt.md"
CommandRegistrar._ensure_inside(prompt_file, prompts_dir)
prompt_file.parent.mkdir(parents=True, exist_ok=True)
prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n", encoding="utf-8")
@staticmethod

View File

@@ -152,6 +152,8 @@ def add_source(
# keeps that ValueError inside the guard instead of leaking a raw
# traceback past the CLI's `except BundlerError`. Reuse the value below.
hostname = parsed.hostname
# Accessing ``port`` performs urllib's syntax/range validation.
_ = parsed.port
except ValueError as exc:
raise BundlerError(f"Invalid catalog url: '{url}'.") from exc
if not (parsed.scheme or parsed.path):

View File

@@ -144,6 +144,7 @@ class CatalogEntry:
license: str
download_url: str
requires_speckit_version: str
sha256: str | None = None
provides: dict[str, int] = field(default_factory=dict)
repository: str | None = None
tags: tuple[str, ...] = ()
@@ -186,6 +187,11 @@ class CatalogEntry:
license=str(data.get("license", "")).strip(),
download_url=str(data.get("download_url", "")).strip(),
requires_speckit_version=str(requires.get("speckit_version", "")).strip(),
sha256=(
None
if data.get("sha256") is None
else str(data["sha256"]).strip()
),
provides=dict(provides_raw),
repository=(str(data["repository"]) if data.get("repository") else None),
tags=_parse_tags(data.get("tags"), entry_id),
@@ -198,6 +204,7 @@ class CatalogEntry:
description=self.description, author=self.author, license=self.license,
download_url=self.download_url,
requires_speckit_version=self.requires_speckit_version,
sha256=self.sha256,
provides=self.provides, repository=self.repository, tags=self.tags,
verified=self.verified, source_id=source.id,
source_policy=source.install_policy,

View File

@@ -16,6 +16,7 @@ from urllib.parse import ParseResult, urlparse
from urllib.request import url2pathname
from ..._assets import _locate_core_pack, _repo_root
from ..._download_security import MAX_JSON_CATALOG_BYTES, read_response_limited
from .. import BundlerError
from ..lib.yamlio import loads_json
from ..models.catalog import CatalogSource
@@ -76,6 +77,8 @@ def _validate_remote_url(source_id: str, url: str) -> None:
try:
parsed = urlparse(url)
hostname = parsed.hostname
# Accessing ``port`` performs urllib's syntax/range validation.
_ = parsed.port
except ValueError:
raise BundlerError(
f"Catalog '{source_id}' URL is malformed: {url}"
@@ -117,7 +120,15 @@ def make_catalog_fetcher(*, allow_network: bool = True):
def fetch(source: CatalogSource) -> dict:
url = source.url
parsed = urlparse(url)
try:
parsed = urlparse(url)
# Keep malformed authorities and ports inside the BundlerError
# contract even when a config file was edited by hand.
_ = parsed.port
except ValueError:
raise BundlerError(
f"Catalog {source.id!r} URL is malformed: {url!r}"
) from None
scheme = parsed.scheme.lower()
if scheme == "builtin":
@@ -180,7 +191,12 @@ def _http_get_json(source_id: str, url: str) -> dict:
) as response:
final_url = response.geturl()
_validate_remote_url(source_id, final_url)
raw = response.read().decode("utf-8")
raw = read_response_limited(
response,
max_bytes=MAX_JSON_CATALOG_BYTES,
error_type=BundlerError,
label=f"bundle catalog '{source_id}'",
).decode("utf-8")
except BundlerError:
raise
except Exception as exc: # noqa: BLE001

View File

@@ -14,6 +14,7 @@ from pathlib import Path
import typer
from ..._console import console, err_console
from ..._download_security import MAX_DOWNLOAD_BYTES, read_response_limited
from ...bundler import BundlerError
from ...bundler.lib.project import (
active_integration,
@@ -337,6 +338,10 @@ def bundle_install(
local_manifest = _local_manifest_source(bundle_id)
if local_manifest is not None:
manifest = local_manifest
_validate_manifest_structure(
manifest,
source=f"Local bundle source {bundle_id!r}",
)
else:
stack = _build_stack(project_root or Path.cwd(), offline=offline)
resolved = stack.resolve(bundle_id)
@@ -350,6 +355,16 @@ def bundle_install(
if project_root is None:
init_integration = _resolve_init_integration(integration, manifest)
# Resolve all hard compatibility gates before ``specify init``.
# Otherwise an incompatible but structurally valid bundle would
# initialize a project and only then fail its version/integration
# checks, leaving state behind after a failed install.
resolve_install_plan(
manifest,
speckit_version=_speckit_version(),
active_integration=init_integration,
integration_explicit=True,
)
console.print(
f"[cyan]No Spec Kit project here; initializing with integration "
f"'{init_integration}'…[/cyan]"
@@ -711,17 +726,24 @@ def _local_manifest_source(arg: str):
if candidate.suffix == ".zip":
import io
import zipfile
import yaml as _yaml
with zipfile.ZipFile(candidate) as archive:
from ..._download_security import open_zip_bounded, read_zip_member_limited
with open_zip_bounded(candidate, error_type=BundlerError) as archive:
try:
raw = archive.read("bundle.yml")
archive.getinfo("bundle.yml")
except KeyError as exc:
raise BundlerError(
f"Artifact '{candidate}' does not contain a bundle.yml."
) from exc
raw = read_zip_member_limited(
archive,
"bundle.yml",
error_type=BundlerError,
label="bundle manifest",
)
data = _yaml.safe_load(io.BytesIO(raw))
return BundleManifest.from_dict(data)
@@ -805,7 +827,13 @@ def _download_manifest(resolved, *, offline: bool):
f"Network access disabled; cannot download bundle '{resolved.entry.id}' "
f"from {url}."
)
return _download_remote_manifest(resolved.entry.id, url)
manifest = _download_remote_manifest(
resolved.entry.id,
url,
expected_sha256=getattr(resolved.entry, "sha256", None),
)
_validate_catalog_manifest(resolved.entry, manifest)
return manifest
def _require_https(label: str, url: str) -> None:
@@ -817,6 +845,8 @@ def _require_https(label: str, url: str) -> None:
try:
parsed = urlparse(url)
hostname = parsed.hostname
# Accessing ``port`` performs urllib's syntax/range validation.
_ = parsed.port
except ValueError:
raise BundlerError(
f"Refusing to download {label}: URL is malformed: {url}"
@@ -830,7 +860,12 @@ def _require_https(label: str, url: str) -> None:
raise BundlerError(f"Refusing to download {label} from URL with no host: {url}")
def _download_remote_manifest(entry_id: str, url: str):
def _download_remote_manifest(
entry_id: str,
url: str,
*,
expected_sha256: str | None = None,
):
"""Fetch a remote bundle artifact over HTTPS and extract its manifest."""
import io
import tempfile
@@ -842,6 +877,7 @@ def _download_remote_manifest(entry_id: str, url: str):
from ...authentication.http import github_provider_hosts, open_url
from ..._github_http import resolve_github_release_asset_api_url
from ...bundler.models.manifest import BundleManifest
from ...shared_infra import verify_archive_sha256
def _validate_redirect(old_url: str, new_url: str) -> None:
_require_https(f"bundle '{entry_id}'", new_url)
@@ -879,7 +915,18 @@ def _download_remote_manifest(entry_id: str, url: str):
extra_headers=extra_headers,
) as resp:
_require_https(f"bundle '{entry_id}'", resp.geturl())
raw = resp.read()
raw = read_response_limited(
resp,
max_bytes=MAX_DOWNLOAD_BYTES,
error_type=BundlerError,
label=f"bundle '{entry_id}' download",
)
verify_archive_sha256(
raw,
expected_sha256,
entry_id,
BundlerError,
)
except BundlerError:
raise
except Exception as exc: # noqa: BLE001
@@ -940,6 +987,38 @@ def _download_remote_manifest(entry_id: str, url: str):
) from exc
def _validate_manifest_structure(manifest, *, source: str) -> None:
"""Reject a malformed manifest before any project mutation can occur."""
from ...bundler.services.validator import validate_manifest
report = validate_manifest(manifest)
if report.ok:
return
raise BundlerError(
f"{source} contains an invalid bundle manifest:\n - "
+ "\n - ".join(report.errors)
)
def _validate_catalog_manifest(entry, manifest) -> None:
"""Bind a downloaded manifest to the catalog identity that selected it."""
if manifest.bundle.id != entry.id:
raise BundlerError(
f"Downloaded bundle id mismatch: catalog entry {entry.id!r} points to "
f"a manifest for {manifest.bundle.id!r}."
)
if manifest.bundle.version != entry.version:
raise BundlerError(
f"Downloaded bundle version mismatch for {entry.id!r}: catalog declares "
f"{entry.version!r}, but the manifest declares "
f"{manifest.bundle.version!r}."
)
_validate_manifest_structure(
manifest,
source=f"Downloaded bundle {entry.id!r}",
)
def register(app: typer.Typer) -> None:
"""Attach the bundle command group to the root Typer app."""
app.add_typer(bundle_app, name="bundle")

View File

@@ -17,7 +17,6 @@ import re
import shutil
import stat
import tempfile
import zipfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
@@ -29,6 +28,13 @@ from packaging import version as pkg_version
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from .._assets import _locate_core_pack, _repo_root
from .._download_security import (
MAX_JSON_CATALOG_BYTES,
build_safe_download_path,
is_https_or_localhost_http,
read_response_limited,
safe_extract_zip,
)
from .._init_options import is_ai_skills_enabled
from .._invocation_style import is_dollar_skills_agent, is_slash_skills_agent
from .._utils import dump_frontmatter, relative_extension_path_violation, version_satisfies
@@ -398,6 +404,12 @@ class ExtensionManifest:
raise ValidationError(
f"Aliases for command '{cmd['name']}' must be strings"
)
alias_reason = relative_extension_path_violation(alias)
if alias_reason:
raise ValidationError(
f"Invalid alias {alias!r} for command "
f"'{cmd['name']}': {alias_reason}"
)
# Rewrite any hook command references that pointed at a renamed command or
# an alias-form ref (ext.cmd → speckit.ext.cmd). Always emit a warning when
@@ -804,7 +816,7 @@ class ExtensionManager:
- primary commands must use this extension's namespace
- command namespaces must not shadow core commands
- duplicate command/alias names inside one manifest are rejected
- aliases are validated for type and uniqueness only (no pattern enforcement)
- aliases are free-form but must remain safe relative output paths
Args:
manifest: Parsed extension manifest
@@ -841,6 +853,12 @@ class ExtensionManager:
f"{kind.capitalize()} for command '{primary_name}' must be a string"
)
path_reason = relative_extension_path_violation(name)
if path_reason:
raise ValidationError(
f"Invalid {kind} {name!r}: {path_reason}"
)
# Enforce canonical pattern only for primary command names;
# aliases are free-form to preserve community extension compat.
if kind == "command":
@@ -1006,18 +1024,21 @@ class ExtensionManager:
return _ignore
def _get_skills_dir(self) -> Optional[Path]:
def _get_skills_dir(self, *, create: bool = True) -> Optional[Path]:
"""Return the active skills directory for extension skill registration.
Delegates to :func:`resolve_active_skills_dir` which reads
init-options, applies the Kimi native-skills fallback, and
safely creates the directory when ``ai_skills`` is enabled.
safely creates the directory when ``ai_skills`` is enabled and
``create`` is true. Read-only callers can pass ``create=False`` to
resolve the configured target without changing the filesystem.
Returns ``None`` (instead of raising) when the directory cannot
be created due to symlink, containment, or permission issues so
that callers can fall back gracefully.
"""
from .. import (
_get_skills_dir as resolve_configured_skills_dir,
_print_cli_warning,
load_init_options,
resolve_active_skills_dir,
@@ -1039,6 +1060,41 @@ class ExtensionManager:
return None
return skills_dir
opts = load_init_options(self.project_root)
if not isinstance(opts, dict):
return None
selected_ai = opts.get("ai")
if not isinstance(selected_ai, str) or not selected_ai:
return None
from ..agents import CommandRegistrar
registrar = CommandRegistrar()
agent_config = registrar.AGENT_CONFIGS.get(selected_ai)
ai_skills_enabled = is_ai_skills_enabled(opts)
if not create:
if not ai_skills_enabled and selected_ai != "kimi":
return None
configured_skills_dir = resolve_configured_skills_dir(
self.project_root, selected_ai
)
from ..shared_infra import _validate_safe_shared_directory
try:
_validate_safe_shared_directory(
self.project_root, configured_skills_dir
)
except (OSError, ValueError):
return None
skills_dir = configured_skills_dir
if agent_config and agent_config.get("extension") == "/SKILL.md":
skills_dir = registrar._resolve_agent_dir(
selected_ai, agent_config, self.project_root
)
if ai_skills_enabled:
return skills_dir
return skills_dir if skills_dir.is_dir() else None
try:
skills_dir = resolve_active_skills_dir(self.project_root)
except (ValueError, OSError) as exc:
@@ -1053,24 +1109,96 @@ class ExtensionManager:
if skills_dir is None:
return None
opts = load_init_options(self.project_root)
if not isinstance(opts, dict):
return _ensure_usable(skills_dir)
selected_ai = opts.get("ai")
if not isinstance(selected_ai, str) or not selected_ai:
return _ensure_usable(skills_dir)
from ..agents import CommandRegistrar
registrar = CommandRegistrar()
agent_config = registrar.AGENT_CONFIGS.get(selected_ai)
if agent_config and agent_config.get("extension") == "/SKILL.md":
agent_skills_dir = registrar._resolve_agent_dir(
skills_dir = registrar._resolve_agent_dir(
selected_ai, agent_config, self.project_root
)
return _ensure_usable(agent_skills_dir)
return _ensure_usable(skills_dir)
@staticmethod
def _skill_name_for_command(command_name: str) -> str:
"""Return the generated skill directory name for an extension command."""
short_name = command_name
if short_name.startswith("speckit."):
short_name = short_name[len("speckit.") :]
return f"speckit-{short_name.replace('.', '-')}"
def _active_command_registration_scope(self) -> Optional[set[str]]:
"""Return the agents a new extension install may render commands for.
``None`` means legacy detection-based registration when init-options
is absent. An empty set means registration must fail closed.
"""
from .. import load_init_options
from .._init_options import (
MISSING_INIT_OPTIONS_FILE,
resolve_active_agent_for_registration,
)
active_agent = resolve_active_agent_for_registration(self.project_root)
if active_agent is MISSING_INIT_OPTIONS_FILE:
return None
if active_agent is None:
return set()
from ..agents import CommandRegistrar as AgentRegistrar
agent_config = AgentRegistrar().AGENT_CONFIGS.get(active_agent)
if (
agent_config
and is_ai_skills_enabled(load_init_options(self.project_root))
and agent_config.get("extension") != "/SKILL.md"
):
# Command-backed integrations render extension artifacts through
# _register_extension_skills while their skills mode is active.
return set()
return {active_agent}
def _command_registration_targets(self) -> Dict[str, Path]:
"""Return current or recoverable command roots for a new install."""
from ..agents import CommandRegistrar as AgentRegistrar
registrar = AgentRegistrar()
agent_scope = self._active_command_registration_scope()
active_skills_agent = registrar._active_skills_agent(self.project_root)
recoverable_active_skills_dir = (
self._get_skills_dir(create=False)
if active_skills_agent is not None
else None
)
targets: Dict[str, Path] = {}
for agent_name, agent_config in registrar.AGENT_CONFIGS.items():
if agent_scope is not None and agent_name not in agent_scope:
continue
active_skills_output = (
agent_name == active_skills_agent
and agent_config.get("extension") == "/SKILL.md"
)
commands_dir = registrar._resolve_agent_dir(
agent_name, agent_config, self.project_root
)
active_output_is_recoverable = (
active_skills_output
and recoverable_active_skills_dir is not None
and registrar._same_lexical_path(
commands_dir, recoverable_active_skills_dir
)
)
detect_dir = agent_config.get("detect_dir")
if (
detect_dir
and not (self.project_root / detect_dir).is_dir()
and not active_output_is_recoverable
):
continue
if commands_dir.is_dir() or active_output_is_recoverable:
targets[agent_name] = commands_dir
return targets
def _register_commands_for_active_agent(
self,
manifest: ExtensionManifest,
@@ -1103,16 +1231,10 @@ class ExtensionManager:
Mapping of agent name to registered command names, matching the
``registered_commands`` registry shape.
"""
from .. import load_init_options
from .._init_options import (
MISSING_INIT_OPTIONS_FILE,
resolve_active_agent_for_registration,
)
registrar = CommandRegistrar()
active_agent = resolve_active_agent_for_registration(self.project_root)
agent_scope = self._active_command_registration_scope()
if active_agent is MISSING_INIT_OPTIONS_FILE:
if agent_scope is None:
return registrar.register_commands_for_all_agents(
manifest,
extension_dir,
@@ -1121,30 +1243,13 @@ class ExtensionManager:
create_missing_active_skills_dir=True,
)
if active_agent is None:
if not agent_scope:
# init-options.json exists but could not provide a valid active
# agent (corrupted/unreadable/non-object JSON, or a malformed
# "ai" value). Fail closed instead of falling back to all agents
# or passing a non-string key into AGENT_CONFIGS.get() below,
# which would raise TypeError for unhashable values like a list.
# agent, or the active command-backed integration is in skills
# mode. Fail closed instead of falling back to all agents.
return {}
init_options = load_init_options(self.project_root)
# A recorded active key with no registrar config (e.g. "generic",
# deliberately excluded from AGENT_CONFIGS) has nothing to register
# through this path, but it is still an active integration. Passing
# it as only_agent below naturally yields no matches instead of
# falling back to registering every detected agent.
agent_config = registrar.AGENT_CONFIGS.get(active_agent)
if (
agent_config
and is_ai_skills_enabled(init_options)
and agent_config.get("extension") != "/SKILL.md"
):
# Active agent runs skills mode: extension artifacts render as
# skills via _register_extension_skills, not as command files.
return {}
active_agent = next(iter(agent_scope))
# Route through the all-agents pass restricted to the active agent so
# detection and missing-skills-dir recovery safeguards still apply.
@@ -1244,10 +1349,7 @@ class ExtensionManager:
# Derive skill name from command name using the same hyphenated
# convention as hook rendering and preset skill registration.
short_name_raw = cmd_name
if short_name_raw.startswith("speckit."):
short_name_raw = short_name_raw[len("speckit.") :]
skill_name = f"speckit-{short_name_raw.replace('.', '-')}"
skill_name = self._skill_name_for_command(cmd_name)
# Check if skill already exists before creating the directory
skill_subdir = skills_dir / skill_name
@@ -1255,6 +1357,9 @@ class ExtensionManager:
cache_root = extension_dir / ".specify-dev" / "extension-skills"
cache_file = cache_root / skill_name / "SKILL.md"
use_dev_symlink = link_outputs and not agent_config.get("dev_no_symlink")
skill_dir_preexists = (
skill_subdir.exists() or skill_subdir.is_symlink()
)
CommandRegistrar._ensure_inside(cache_file, cache_root)
if skill_file.exists() or skill_file.is_symlink():
is_expected_dev_symlink = self._is_expected_dev_symlink(
@@ -1265,6 +1370,11 @@ class ExtensionManager:
# to be refreshed on a subsequent dev install.
if not is_expected_dev_symlink:
continue
elif skill_dir_preexists:
# Never add files to a pre-existing user directory. Without a
# verifiable SKILL.md ownership marker, rollback/removal cannot
# distinguish our output from unrelated user artifacts.
continue
# Create skill directory; track whether we created it so we can clean
# up safely if reading the source file subsequently fails.
@@ -1357,6 +1467,97 @@ class ExtensionManager:
except OSError:
return False
def _find_extension_skill_dirs(
self,
skill_names: List[str],
extension_id: str,
skills_dir: Optional[Path] = None,
*,
create_skills_dir: bool = True,
) -> List[Path]:
"""Return owned skill directories that removal is allowed to delete.
This is the single discovery path used by both update backups and
unregistration. Keeping the ownership and containment checks shared
prevents rollback from backing up a different set of artifacts than
``remove()`` later deletes.
"""
if not skill_names:
return []
requested_skills_dir = skills_dir
project_root = Path(os.path.abspath(self.project_root))
fallback_candidates = {
candidate: trusted_root
for candidate, trusted_root in self._extension_skill_candidate_dirs().items()
if trusted_root == project_root
}
if requested_skills_dir is None:
candidate_dirs = dict(fallback_candidates)
elif skills_dir:
candidate = Path(os.path.abspath(skills_dir))
trusted_root = self._extension_skill_trusted_root(candidate)
candidate_dirs = (
{candidate: trusted_root} if trusted_root is not None else {}
)
else:
candidate_dirs = {}
from ..shared_infra import _validate_safe_shared_directory
owned_dirs: List[Path] = []
seen_dirs: set[Path] = set()
for skills_candidate, trusted_root in candidate_dirs.items():
try:
# Validate roots lexically before resolving them. Otherwise a
# symlinked root resolves to its target and makes descendants
# appear contained within itself. Explicit configured roots can
# legitimately be global (for example Hermes), while fallback
# roots remain restricted to this project.
_validate_safe_shared_directory(trusted_root, skills_candidate)
except (OSError, ValueError):
continue
if not skills_candidate.is_dir():
continue
for skill_name in skill_names:
# Guard against path traversal from a corrupted registry entry.
sn_path = Path(skill_name)
if sn_path.is_absolute() or len(sn_path.parts) != 1:
continue
skill_subdir = skills_candidate / skill_name
try:
_validate_safe_shared_directory(trusted_root, skill_subdir)
resolved_skill_dir = skill_subdir.resolve()
except (OSError, ValueError):
continue
if resolved_skill_dir in seen_dirs or not skill_subdir.is_dir():
continue
skill_md = skill_subdir / "SKILL.md"
if not skill_md.is_file():
continue
try:
from ..agents import CommandRegistrar as _Registrar
raw = skill_md.read_text(encoding="utf-8")
fm, _ = _Registrar.parse_frontmatter(raw)
source = (
fm.get("metadata", {}).get("source", "")
if isinstance(fm, dict)
else ""
)
if source != f"extension:{extension_id}":
continue
except Exception:
# If ownership cannot be verified, preserve the directory.
continue
seen_dirs.add(resolved_skill_dir)
owned_dirs.append(resolved_skill_dir)
return owned_dirs
def _extension_skill_trusted_root(self, candidate: Path) -> Optional[Path]:
"""Return the project or home root allowed to contain *candidate*."""
candidate = Path(os.path.abspath(candidate))
@@ -1428,157 +1629,10 @@ class ExtensionManager:
every configured agent's skills directory is scanned
instead of resolving just the currently active one.
"""
if not skill_names:
return
from ..shared_infra import _validate_safe_shared_directory
if skills_dir:
# Reject the candidate directory itself (any path component,
# including the final one) if it's a symlink escaping the
# trusted project/home root, before probing or deleting anything
# inside it.
# A caller-supplied skills_dir (e.g. a specific agent's
# directory resolved without side effects) could have been
# replaced with a symlink between registration and removal;
# resolving it and only checking children relative to the
# already-resolved candidate (the previous approach) would
# silently follow the symlink instead of rejecting it.
trusted_root = self._extension_skill_trusted_root(skills_dir)
if trusted_root is None:
return
try:
_validate_safe_shared_directory(trusted_root, skills_dir)
except (ValueError, OSError):
return
# Fast path: we know the exact skills directory
for skill_name in skill_names:
# Guard against path traversal from a corrupted registry entry:
# reject names that are absolute, contain path separators, or
# resolve to a path outside the skills directory.
sn_path = Path(skill_name)
if sn_path.is_absolute() or len(sn_path.parts) != 1:
continue
skill_subdir = skills_dir / skill_name
# Validate every path component down to the skill's own
# subdirectory, not just the already-validated parent
# skills_dir: a per-skill child can itself be a symlink to
# another directory whose *resolved* target still lands
# inside this same (safe) skills root, which the previous
# resolve()+relative_to() containment check alone would
# not catch. Reject the symlink outright rather than
# following it, even when the target is otherwise
# in-bounds (#2948).
try:
_validate_safe_shared_directory(trusted_root, skill_subdir)
except (ValueError, OSError):
continue
if not skill_subdir.is_dir():
continue
# Safety check: only delete if SKILL.md exists and its
# metadata.source matches exactly this extension — mirroring
# the fallback branch — so a corrupted registry entry cannot
# delete an unrelated user skill.
skill_md = skill_subdir / "SKILL.md"
if not skill_md.is_file():
continue
try:
from ..agents import CommandRegistrar as _Registrar
raw = skill_md.read_text(encoding="utf-8")
# Parse on the ``---`` delimiter *line*, not any ``---``
# substring: a description containing ``---`` would trip a
# raw ``split("---", 2)`` and hide metadata.source, so this
# extension's own skill would look unrelated and be left
# orphaned. Mirrors the #3590 parse_frontmatter fix.
fm, _ = _Registrar.parse_frontmatter(raw)
source = (
fm.get("metadata", {}).get("source", "")
if isinstance(fm, dict)
else ""
)
if source != f"extension:{extension_id}":
continue
except (OSError, UnicodeDecodeError, Exception):
continue
shutil.rmtree(skill_subdir)
else:
# Fallback: scan all possible agent skills directories
for (
skills_candidate,
trusted_root,
) in self._extension_skill_candidate_dirs().items():
# Only project-local skills directories are eligible: the
# flat (non-agent-scoped) registered_skills provenance
# cannot prove a home-directory skill belongs to this
# project, so deleting there could remove another
# project's files. Revisit if registry entries ever record
# the owning project/agent.
if trusted_root != Path(os.path.abspath(self.project_root)):
continue
if not skills_candidate.is_dir():
continue
# Reject the candidate directory itself (any path
# component) if it's a symlink escaping the project
# root, before probing or deleting anything inside it —
# same guard as the fast path above.
try:
_validate_safe_shared_directory(
trusted_root, skills_candidate
)
except (ValueError, OSError):
continue
for skill_name in skill_names:
# Same path-traversal guard as the fast path above
sn_path = Path(skill_name)
if sn_path.is_absolute() or len(sn_path.parts) != 1:
continue
skill_subdir = skills_candidate / skill_name
# Validate every path component down to the skill's
# own subdirectory, not just the already-validated
# candidate parent: a per-skill child can itself be a
# symlink to another directory whose resolved target
# still lands inside this same candidate, which the
# previous resolve()+relative_to() containment check
# alone would not catch (#2948).
try:
_validate_safe_shared_directory(
trusted_root, skill_subdir
)
except (ValueError, OSError):
continue
if not skill_subdir.is_dir():
continue
# Safety check: only delete if SKILL.md exists and its
# metadata.source matches exactly this extension. If the
# file is missing or unreadable we skip to avoid deleting
# unrelated user-created directories.
skill_md = skill_subdir / "SKILL.md"
if not skill_md.is_file():
continue
try:
from ..agents import CommandRegistrar as _Registrar
raw = skill_md.read_text(encoding="utf-8")
# Parse on the ``---`` delimiter *line*, not any ``---``
# substring: a description containing ``---`` would trip
# a raw ``split("---", 2)`` and hide metadata.source, so
# this extension's own skill would look unrelated and be
# left orphaned. Mirrors the #3590 parse_frontmatter fix.
fm, _ = _Registrar.parse_frontmatter(raw)
source = (
fm.get("metadata", {}).get("source", "")
if isinstance(fm, dict)
else ""
)
# Only remove skills explicitly created by this extension
if source != f"extension:{extension_id}":
continue
except (OSError, UnicodeDecodeError, Exception):
# If we can't verify, skip to avoid accidental deletion
continue
shutil.rmtree(skill_subdir)
for skill_subdir in self._find_extension_skill_dirs(
skill_names, extension_id, skills_dir=skills_dir
):
shutil.rmtree(skill_subdir)
def _extension_owned_skill_names(
self, skill_names: List[str], extension_id: str
@@ -2329,21 +2383,7 @@ class ExtensionManager:
with tempfile.TemporaryDirectory() as tmpdir:
temp_path = Path(tmpdir)
# Extract ZIP safely (prevent Zip Slip attack)
with zipfile.ZipFile(zip_path, "r") as zf:
# Validate all paths first before extracting anything
temp_path_resolved = temp_path.resolve()
for member in zf.namelist():
member_path = (temp_path / member).resolve()
# Use is_relative_to for safe path containment check
try:
member_path.relative_to(temp_path_resolved)
except ValueError:
raise ValidationError(
f"Unsafe path in ZIP archive: {member} (potential path traversal)"
)
# Only extract after all paths are validated
zf.extractall(temp_path)
safe_extract_zip(zip_path, temp_path, error_type=ValidationError)
# Find extension directory (may be nested)
extension_dir = temp_path
@@ -3351,7 +3391,14 @@ class ExtensionCatalog(CatalogStackBase):
final_url = response.geturl()
if final_url != entry.url:
self._validate_catalog_url(final_url)
catalog_data = json.loads(response.read())
catalog_data = json.loads(
read_response_limited(
response,
max_bytes=MAX_JSON_CATALOG_BYTES,
error_type=ExtensionError,
label=f"extension catalog {entry.url}",
)
)
self._validate_catalog_payload(catalog_data, entry.url)
@@ -3539,7 +3586,14 @@ class ExtensionCatalog(CatalogStackBase):
final_url = response.geturl()
if final_url != catalog_url:
self._validate_catalog_url(final_url)
catalog_data = json.loads(response.read())
catalog_data = json.loads(
read_response_limited(
response,
max_bytes=MAX_JSON_CATALOG_BYTES,
error_type=ExtensionError,
label=f"extension catalog {catalog_url}",
)
)
# Validate catalog structure. Reuses the same helper as
# ``_fetch_single_catalog`` so all three branches (root type,
@@ -3697,6 +3751,10 @@ class ExtensionCatalog(CatalogStackBase):
download_url = ext_info.get("download_url")
if not download_url:
raise ExtensionError(f"Extension '{extension_id}' has no download URL")
if not isinstance(download_url, str):
raise ExtensionError(
f"Extension download URL is malformed: {download_url}"
)
# Validate download URL requires HTTPS (prevent man-in-the-middle attacks)
from urllib.parse import urlparse
@@ -3710,12 +3768,16 @@ class ExtensionCatalog(CatalogStackBase):
try:
parsed = urlparse(download_url)
hostname = parsed.hostname
parsed.port
except ValueError:
raise ExtensionError(
f"Extension download URL is malformed: {download_url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
if not hostname:
raise ExtensionError(
f"Extension download URL is malformed: {download_url}"
)
if not is_https_or_localhost_http(download_url):
raise ExtensionError(
f"Extension download URL must use HTTPS: {download_url}"
)
@@ -3723,11 +3785,16 @@ class ExtensionCatalog(CatalogStackBase):
# Determine target path
if target_dir is None:
target_dir = self.cache_dir / "downloads"
target_dir.mkdir(parents=True, exist_ok=True)
target_dir = Path(target_dir)
version = ext_info.get("version", "unknown")
zip_filename = f"{extension_id}-{version}.zip"
zip_path = target_dir / zip_filename
zip_path = build_safe_download_path(
target_dir,
extension_id,
version,
error_type=ExtensionError,
label="extension",
)
target_dir.mkdir(parents=True, exist_ok=True)
extra_headers = None
resolved_download_url = self._resolve_github_release_asset_api_url(download_url)
@@ -3740,7 +3807,11 @@ class ExtensionCatalog(CatalogStackBase):
with self._open_url(
download_url, timeout=60, extra_headers=extra_headers
) as response:
zip_data = response.read()
zip_data = read_response_limited(
response,
error_type=ExtensionError,
label=f"extension '{extension_id}' download",
)
verify_archive_sha256(
zip_data, ext_info.get("sha256"), extension_id, ExtensionError

View File

@@ -8,12 +8,14 @@ which re-fetch from the parent package at call time so test monkeypatching of
"""
from __future__ import annotations
import hashlib
import os
import shutil
import tempfile
import zipfile
from pathlib import Path
from typing import Optional
from uuid import uuid4
import typer
import yaml
@@ -23,6 +25,15 @@ from rich.table import Table
from .._console import console
from .._assets import get_speckit_version
from .._download_security import (
is_https_or_localhost_http,
normalize_zip_member_name,
open_zip_bounded,
portable_zip_path_key,
read_response_limited,
read_zip_member_limited,
)
from .._init_options import is_ai_skills_enabled
extension_app = typer.Typer(
name="extension",
@@ -443,14 +454,17 @@ def extension_add(
# "Invalid URL" message instead of leaking a raw traceback past the
# CLI. Reuse the value below.
hostname = parsed.hostname
parsed.port
except ValueError:
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
raise typer.Exit(1)
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
if not hostname:
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
raise typer.Exit(1)
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
if not is_https_or_localhost_http(from_url):
console.print("[red]Error:[/red] URL must use HTTPS for security.")
console.print("HTTP is only allowed for localhost URLs.")
console.print("HTTP is only allowed for loopback URLs.")
raise typer.Exit(1)
safe_url = _escape_markup(from_url)
@@ -533,7 +547,11 @@ def extension_add(
with dl_catalog._open_url(
download_url, timeout=60, extra_headers=extra_headers
) as response:
zip_data = response.read()
zip_data = read_response_limited(
response,
error_type=ExtensionError,
label=f"extension {from_url}",
)
if not zipfile.is_zipfile(io.BytesIO(zip_data)):
console.print(
@@ -1079,6 +1097,7 @@ def extension_update(
from . import (
ExtensionManager,
ExtensionCatalog,
ExtensionManifest,
ExtensionError,
ValidationError,
CommandRegistrar,
@@ -1193,9 +1212,17 @@ def extension_update(
console.print(f"📦 Updating {safe_ext_name}...")
# Backup paths
backup_base = manager.extensions_dir / ".backup" / f"{extension_id}-update"
backup_root = manager.extensions_dir / ".backup"
backup_key = hashlib.sha256(
extension_id.encode("utf-8")
).hexdigest()[:16]
backup_base = (
backup_root
/ f"update-{backup_key}-{uuid4().hex}"
)
backup_ext_dir = backup_base / "extension"
backup_commands_dir = backup_base / "commands"
backup_skills_dir = backup_base / "skills"
backup_config_dir = backup_base / "config"
# Store backup state
@@ -1203,14 +1230,125 @@ def extension_update(
backup_installed = UNSET # Original installed list from extensions.yml
backup_hooks = None # None means backup step 4 not yet reached; {} or {...} means backup was captured
backed_up_command_files = {}
backed_up_command_symlinks = {}
backed_up_skill_dirs = {}
new_command_dirs_absent_before_update = []
new_command_paths_absent_before_update = []
new_skill_names = []
new_skill_paths_absent_before_update = []
# Validation failures must not rewrite an untouched installation.
installation_modified = False
zip_cleanup_error = None
backup_created_by_attempt = False
def backup_command_artifact(original_file, backup_file):
"""Back up one command artifact once, preserving its full path."""
nonlocal backup_created_by_attempt
original_key = str(original_file)
if original_key in backed_up_command_files:
return
if original_file.is_symlink():
backed_up_command_symlinks[original_key] = os.readlink(
original_file
)
else:
if original_file.stat().st_nlink > 1:
raise RuntimeError(
"Cannot safely update hard-linked generated "
f"artifact '{original_file}'"
)
backup_created_by_attempt = True
backup_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(original_file, backup_file)
backed_up_command_files[original_key] = str(backup_file)
def restore_command_artifact(original_path, backup_path):
"""Restore one regular file or symlink without following it."""
original_key = str(original_path)
original_file = Path(original_path)
backup_file = Path(backup_path)
symlink_state = backed_up_command_symlinks.get(
original_key
)
if symlink_state is not None:
if original_file.is_symlink() or original_file.is_file():
original_file.unlink()
elif original_file.exists():
raise RuntimeError(
"Command rollback found an unexpected directory "
f"at '{original_file}'"
)
original_file.parent.mkdir(parents=True, exist_ok=True)
os.symlink(symlink_state, original_file)
return
if not backup_file.is_file() or backup_file.is_symlink():
raise RuntimeError(
"Command rollback backup is missing for "
f"'{original_file}'"
)
if original_file.is_symlink() or original_file.is_file():
original_file.unlink()
elif original_file.exists():
raise RuntimeError(
"Command rollback found an unexpected directory "
f"at '{original_file}'"
)
original_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(backup_file, original_file)
def remember_absent_parent_dirs(artifact_path, root_dir):
"""Remember absent parents a failed renderer may create."""
boundary = root_dir.parent
if root_dir.is_relative_to(project_root):
boundary = project_root
parent = artifact_path.parent
while parent != boundary:
if parent.exists() or parent.is_symlink():
break
new_command_dirs_absent_before_update.append(parent)
parent = parent.parent
def backup_extension_skills(skill_names, *, skills_dir=None):
"""Back up every owned skill directory that remove() may delete."""
nonlocal backup_created_by_attempt
for skill_dir in manager._find_extension_skill_dirs(
skill_names,
extension_id,
skills_dir=skills_dir,
create_skills_dir=False,
):
original_key = str(skill_dir)
if original_key in backed_up_skill_dirs:
continue
backup_created_by_attempt = True
backup_skills_dir.mkdir(parents=True, exist_ok=True)
backup_skill_dir = backup_skills_dir / str(
len(backed_up_skill_dirs)
)
shutil.copytree(skill_dir, backup_skill_dir, symlinks=True)
backed_up_skill_dirs[original_key] = str(backup_skill_dir)
try:
if backup_root.is_symlink():
raise RuntimeError(
"Cannot safely create update backup under symlinked "
f"directory '{backup_root}'"
)
if backup_base.exists() or backup_base.is_symlink():
raise RuntimeError(
"Cannot safely reuse an existing update backup "
f"directory '{backup_base}'"
)
# 1. Backup registry entry (always, even if extension dir doesn't exist)
backup_registry_entry = manager.registry.get(extension_id)
# 2. Backup extension directory
extension_dir = manager.extensions_dir / extension_id
if extension_dir.exists():
backup_created_by_attempt = True
backup_base.mkdir(parents=True, exist_ok=True)
if backup_ext_dir.exists():
shutil.rmtree(backup_ext_dir)
@@ -1234,30 +1372,91 @@ def extension_update(
commands_dir = _AgentReg._resolve_agent_dir(
agent_name, agent_config, project_root
)
dirs_to_backup = [commands_dir]
legacy = agent_config.get("legacy_dir")
if legacy:
legacy_dir = project_root / legacy
if (
legacy_dir.exists()
and legacy_dir != commands_dir
):
dirs_to_backup.append(legacy_dir)
for cmd_name in cmd_names:
output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config)
cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
if cmd_file.exists():
# Mirror the real on-disk layout under the backup dir.
# Skills agents (extension == "/SKILL.md") name every
# command file "SKILL.md", living in a per-command
# subdir (e.g. speckit-plan/SKILL.md). Using cmd_file.name
# alone would collide all of them onto one backup path and
# break rollback; keep the relative path to stay unique.
backup_cmd_path = backup_commands_dir / agent_name / cmd_file.relative_to(commands_dir)
backup_cmd_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(cmd_file, backup_cmd_path)
backed_up_command_files[str(cmd_file)] = str(backup_cmd_path)
output_name = _AgentReg._compute_output_name(
agent_name, cmd_name, agent_config
)
names_to_backup = [output_name]
if (
output_name != cmd_name
and _AgentReg._is_safe_command_name(cmd_name)
):
names_to_backup.append(cmd_name)
for dir_index, target_dir in enumerate(
dirs_to_backup
):
for name in names_to_backup:
cmd_file = (
target_dir
/ f"{name}{agent_config['extension']}"
)
try:
_AgentReg._ensure_inside(
cmd_file, target_dir
)
except ValueError:
continue
if (
cmd_file.exists()
or cmd_file.is_symlink()
):
# Keep both the directory location and
# relative path unique. unregister_commands()
# removes legacy and canonical copies, and
# skills agents place every SKILL.md in its
# own command subdirectory.
backup_cmd_path = (
backup_commands_dir
/ agent_name
/ f"location-{dir_index}"
/ cmd_file.relative_to(target_dir)
)
backup_command_artifact(
cmd_file, backup_cmd_path
)
# Also backup copilot prompt files
if agent_name == "copilot":
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
if prompt_file.exists():
backup_prompt_path = backup_commands_dir / "copilot-prompts" / prompt_file.name
backup_prompt_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(prompt_file, backup_prompt_path)
backed_up_command_files[str(prompt_file)] = str(backup_prompt_path)
prompts_dir = (
project_root / ".github" / "prompts"
)
prompt_file = (
prompts_dir / f"{cmd_name}.prompt.md"
)
try:
_AgentReg._ensure_inside(
prompt_file, prompts_dir
)
except ValueError:
continue
if prompt_file.exists() or prompt_file.is_symlink():
backup_prompt_path = (
backup_commands_dir
/ "copilot-prompts"
/ prompt_file.relative_to(prompts_dir)
)
backup_command_artifact(
prompt_file, backup_prompt_path
)
raw_registered_skills = (
backup_registry_entry.get("registered_skills", [])
if isinstance(backup_registry_entry, dict)
else []
)
registered_skills = manager._valid_name_list(raw_registered_skills)
backup_extension_skills(registered_skills)
# 4. Backup hooks and installed list from extensions.yml
# get_project_config() always normalizes installed->[] and hooks->{},
@@ -1281,24 +1480,107 @@ def extension_update(
try:
# 6. Validate extension ID from ZIP BEFORE modifying installation
# Handle both root-level and nested extension.yml (GitHub auto-generated ZIPs)
with zipfile.ZipFile(zip_path, "r") as zf:
with open_zip_bounded(zip_path) as zf:
import yaml
manifest_data = None
manifest_bytes = None
namelist = zf.namelist()
# First try root-level extension.yml
if "extension.yml" in namelist:
with zf.open("extension.yml") as f:
parsed_manifest = yaml.safe_load(f)
manifest_data = parsed_manifest if parsed_manifest is not None else {}
else:
# Look for extension.yml in a single top-level subdirectory
# (e.g., "repo-name-branch/extension.yml")
manifest_paths = [n for n in namelist if n.endswith("/extension.yml") and n.count("/") == 1]
if len(manifest_paths) == 1:
with zf.open(manifest_paths[0]) as f:
parsed_manifest = yaml.safe_load(f)
manifest_data = parsed_manifest if parsed_manifest is not None else {}
# Read the manifest under a hard size cap: this happens
# before install_from_zip()'s safe_extract_zip(), so a
# raw zf.open().read() here would bypass that bound and
# let a zip-bomb extension.yml exhaust memory.
# Normalize separators before choosing the manifest so
# this pre-scan cannot approve one entry while extraction
# later overwrites it with a backslash alias.
manifest_candidates = []
archive_entries = []
for name in namelist:
normalized_name = normalize_zip_member_name(name)
parts = normalized_name.removesuffix("/").split(
"/"
)
path_key = portable_zip_path_key(normalized_name)
archive_entries.append(
(normalized_name, parts)
)
if (
len(parts) in {1, 2}
and path_key[-1] == "extension.yml"
):
manifest_candidates.append(
(name, normalized_name, path_key)
)
seen_manifest_keys = {}
for name, _normalized_name, path_key in manifest_candidates:
previous = seen_manifest_keys.get(path_key)
if previous is not None:
raise ValueError(
"Downloaded extension archive contains multiple "
"extension.yml manifests"
)
seen_manifest_keys[path_key] = name
for _name, normalized_name, _path_key in manifest_candidates:
if normalized_name.split("/")[-1] != "extension.yml":
raise ValueError(
"Downloaded extension archive manifest "
"filenames must use canonical "
"'extension.yml' casing"
)
root_manifest = next(
(
name
for name, _normalized_name, path_key
in manifest_candidates
if path_key == ("extension.yml",)
),
None,
)
nested_manifests = [
(name, normalized_name)
for name, normalized_name, path_key
in manifest_candidates
if len(path_key) == 2
and path_key[-1] == "extension.yml"
]
manifest_path = root_manifest
if manifest_path is None and len(nested_manifests) == 1:
manifest_path, normalized_manifest_path = (
nested_manifests[0]
)
manifest_root = normalized_manifest_path.split(
"/", 1
)[0]
top_level_dirs = {
parts[0]
for normalized_name, parts in archive_entries
if (
len(parts) > 1
or normalized_name.endswith("/")
)
}
if top_level_dirs != {manifest_root}:
raise ValueError(
"Downloaded extension archive with a "
"nested extension.yml must contain exactly "
"one top-level directory"
)
if manifest_path is not None:
manifest_bytes = read_zip_member_limited(
zf, manifest_path
)
parsed_manifest = yaml.safe_load(
manifest_bytes
)
manifest_data = (
parsed_manifest
if parsed_manifest is not None
else {}
)
if manifest_data is None:
raise ValueError("Downloaded extension archive is missing 'extension.yml'")
@@ -1312,13 +1594,205 @@ def extension_update(
"Invalid extension manifest in downloaded archive: expected 'extension' mapping"
)
zip_extension_id = extension_data.get("id")
# Run the same manifest and compatibility validation as a
# normal install while the existing extension is still
# untouched. Reuse the exact bounded bytes selected above.
if manifest_bytes is None:
raise ValueError(
"Downloaded extension archive is missing 'extension.yml'"
)
with tempfile.TemporaryDirectory(
prefix="speckit-update-manifest-"
) as manifest_tmpdir:
manifest_file = Path(manifest_tmpdir) / "extension.yml"
manifest_file.write_bytes(manifest_bytes)
preflight_manifest = ExtensionManifest(manifest_file)
manager.check_compatibility(
preflight_manifest, speckit_version
)
zip_extension_id = preflight_manifest.id
if zip_extension_id != extension_id:
raise ValueError(
f"Extension ID mismatch: expected '{extension_id}', got '{zip_extension_id}'"
)
expected_version = pkg_version.Version(update["available"])
archive_version = pkg_version.Version(
preflight_manifest.version
)
if archive_version != expected_version:
raise ValueError(
"Extension version mismatch: "
f"expected '{update['available']}', "
f"got '{preflight_manifest.version}'"
)
# Match the remaining deterministic install validation
# before crossing the destructive boundary. The helper
# excludes this extension's current registry entry while
# still detecting namespace, core, duplicate, and
# cross-extension command conflicts.
manager._validate_install_conflicts(preflight_manifest)
new_command_names = list(
manager._collect_manifest_command_names(
preflight_manifest
)
)
new_skill_names = list(
dict.fromkeys(
manager._skill_name_for_command(command_name)
for command_name in new_command_names
)
)
# Command rendering happens before hook registration and
# registry.add(). Preserve every candidate output that
# already exists, and remember paths that are absent now so
# rollback can remove files created before registry state is
# available. Include aliases and Copilot companion prompts.
for (
agent_name,
commands_dir,
) in manager._command_registration_targets().items():
agent_config = registrar.AGENT_CONFIGS[agent_name]
for command_name in new_command_names:
output_name = _AgentReg._compute_output_name(
agent_name, command_name, agent_config
)
command_file = (
commands_dir
/ f"{output_name}{agent_config['extension']}"
)
_AgentReg._ensure_inside(command_file, commands_dir)
backup_command_path = (
backup_commands_dir
/ agent_name
/ command_file.relative_to(commands_dir)
)
if command_file.exists() or command_file.is_symlink():
backup_command_artifact(
command_file, backup_command_path
)
else:
new_command_paths_absent_before_update.append(
command_file
)
remember_absent_parent_dirs(
command_file, commands_dir
)
if agent_name == "copilot":
prompts_dir = (
project_root / ".github" / "prompts"
)
prompt_file = (
prompts_dir / f"{command_name}.prompt.md"
)
_AgentReg._ensure_inside(
prompt_file, prompts_dir
)
if prompt_file.is_symlink():
raise RuntimeError(
"Cannot safely update symlinked Copilot "
f"prompt artifact '{prompt_file}'"
)
backup_prompt_path = (
backup_commands_dir
/ "copilot-prompts"
/ prompt_file.relative_to(prompts_dir)
)
if (
prompt_file.exists()
or prompt_file.is_symlink()
):
backup_command_artifact(
prompt_file, backup_prompt_path
)
else:
new_command_paths_absent_before_update.append(
prompt_file
)
remember_absent_parent_dirs(
prompt_file, prompts_dir
)
new_command_paths_absent_before_update = list(
dict.fromkeys(
new_command_paths_absent_before_update
)
)
new_command_dirs_absent_before_update = list(
dict.fromkeys(
new_command_dirs_absent_before_update
)
)
# A newly introduced command may reuse an existing
# extension-owned skill directory that was not present in
# the old registry. Back it up before cleanup can touch it.
backup_extension_skills(new_skill_names)
new_skills_dir = manager._get_skills_dir(create=False)
if new_skills_dir is not None:
# Unscoped removal deliberately ignores home-scoped
# outputs because the flat registry cannot establish
# project ownership. The active install can still
# replace a marker-owned skill in its explicit root,
# so back up that exact project/home target separately.
backup_extension_skills(
list(
dict.fromkeys(
registered_skills + new_skill_names
)
),
skills_dir=new_skills_dir,
)
init_options = load_init_options(project_root)
if (
isinstance(init_options, dict)
and is_ai_skills_enabled(init_options)
and isinstance(init_options.get("ai"), str)
and init_options["ai"]
):
# resolve_active_skills_dir() first creates the
# configured project-local skills marker. Some
# agents (notably Hermes) then redirect rendered
# skills to a different global root, so snapshot
# both locations for exact rollback.
from .. import _get_skills_dir
configured_skills_dir = _get_skills_dir(
project_root, init_options["ai"]
)
remember_absent_parent_dirs(
configured_skills_dir / ".update-marker",
configured_skills_dir,
)
new_skills_root = new_skills_dir.resolve()
for skill_name in new_skill_names:
skill_path = new_skills_dir / skill_name
resolved_skill_path = skill_path.resolve(strict=False)
resolved_skill_path.relative_to(new_skills_root)
if not (
skill_path.exists() or skill_path.is_symlink()
):
new_skill_paths_absent_before_update.append(
skill_path
)
remember_absent_parent_dirs(
skill_path / "SKILL.md",
new_skills_dir,
)
new_command_dirs_absent_before_update = list(
dict.fromkeys(
new_command_dirs_absent_before_update
)
)
# 7. Remove old extension (handles command file cleanup and registry removal)
installation_modified = True
manager.remove(extension_id, keep_config=True)
# 8. Install new version
@@ -1368,15 +1842,42 @@ def extension_update(
hook["enabled"] = False
hook_executor.save_project_config(config)
finally:
# Clean up downloaded ZIP
# ZIP cleanup is housekeeping: never replace an install
# error or roll back an already committed update because a
# scanner temporarily locks the download on Windows.
if zip_path.exists():
zip_path.unlink()
try:
zip_path.unlink()
except OSError as error:
zip_cleanup_error = error
# 10. Clean up backup on success
if backup_base.exists():
shutil.rmtree(backup_base)
# 10. Clean up backup on success. The update has committed at
# this point, so a locked backup file must not trigger rollback
# of an otherwise successful installation.
cleanup_error = None
if backup_created_by_attempt and backup_base.exists():
try:
shutil.rmtree(backup_base)
except OSError as error:
cleanup_error = error
console.print(f" [green]✓[/green] Updated to v{update['available']}")
if cleanup_error is not None:
console.print(
" [yellow]Warning:[/yellow] Could not fully remove "
"update backup: "
f"{_escape_markup(str(cleanup_error))}"
)
console.print(
" [dim]Backup may remain at: "
f"{_escape_markup(str(backup_base))}[/dim]"
)
if zip_cleanup_error is not None:
console.print(
" [yellow]Warning:[/yellow] Could not remove "
"downloaded update archive: "
f"{_escape_markup(str(zip_cleanup_error))}"
)
updated_extensions.append(ext_name)
except KeyboardInterrupt:
@@ -1384,6 +1885,24 @@ def extension_update(
except Exception as e:
console.print(f" [red]✗[/red] Failed: {_escape_markup(str(e))}")
failed_updates.append((ext_name, str(e)))
if zip_cleanup_error is not None:
console.print(
" [yellow]Warning:[/yellow] Could not remove "
"downloaded update archive: "
f"{_escape_markup(str(zip_cleanup_error))}"
)
if not installation_modified:
if backup_created_by_attempt and backup_base.exists():
try:
shutil.rmtree(backup_base)
except OSError as cleanup_error:
console.print(
" [yellow]Warning:[/yellow] Could not remove "
"untouched-update backup: "
f"{_escape_markup(str(cleanup_error))}"
)
continue
# Rollback on failure
console.print(f" [yellow]↩[/yellow] Rolling back {safe_ext_name}...")
@@ -1400,13 +1919,28 @@ def extension_update(
shutil.copytree(backup_ext_dir, extension_dir)
# Remove any NEW command files created by failed install
# (files that weren't in the original backup)
# (files that weren't in the original backup). Registration
# writes before registry.add(), so start with the paths that
# were absent at the destructive boundary instead of relying
# only on a possibly missing new registry entry.
for command_path in new_command_paths_absent_before_update:
if command_path.is_symlink() or command_path.is_file():
command_path.unlink()
elif command_path.exists():
raise RuntimeError(
"Command rollback found an unexpected directory "
f"at '{command_path}'"
)
new_registered_skills = []
try:
new_registry_entry = manager.registry.get(extension_id)
if new_registry_entry is None or not isinstance(new_registry_entry, dict):
new_registered_commands = {}
else:
new_registered_commands = new_registry_entry.get("registered_commands", {})
new_registered_skills = manager._valid_name_list(
new_registry_entry.get("registered_skills", [])
)
for agent_name, cmd_names in new_registered_commands.items():
if agent_name not in registrar.AGENT_CONFIGS:
continue
@@ -1430,13 +1964,78 @@ def extension_update(
except KeyError:
pass # No new registry entry exists, nothing to clean up
# Restore backed up command files
# Restore command artifacts that existed before the update
# before extension-skill cleanup inspects ownership. A
# failed skills registrar may have overwritten a user's
# pre-existing SKILL.md with extension metadata; restoring
# it first prevents the conservative skill unregistrar from
# misclassifying and deleting the user's whole directory.
for original_path, backup_path in backed_up_command_files.items():
backup_file = Path(backup_path)
if backup_file.exists():
original_file = Path(original_path)
original_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(backup_file, original_file)
restore_command_artifact(
original_path, backup_path
)
# Skill generation happens before hooks and registry.add(),
# so a failed install may have created skills that are not
# recorded in any registry entry yet. Derive names from the
# preflighted manifest as well as any partial new entry.
skills_to_remove = list(
dict.fromkeys(new_skill_names + new_registered_skills)
)
# A write failure can leave a partial skill without valid
# ownership metadata, which the normal conservative
# unregistrar intentionally refuses to delete. Paths that
# were absent at the destructive boundary are safe to
# remove directly during rollback.
for skill_path in new_skill_paths_absent_before_update:
if skill_path.is_symlink() or skill_path.is_file():
skill_path.unlink()
elif skill_path.exists():
shutil.rmtree(skill_path)
manager._unregister_extension_skills(
skills_to_remove, extension_id
)
# Restore all original registered skill artifacts after
# removing skills created by the failed installation.
for original_path, backup_path in backed_up_skill_dirs.items():
backup_skill_dir = Path(backup_path)
if not backup_skill_dir.is_dir():
raise RuntimeError(
"Skill rollback backup is missing for "
f"'{original_path}'"
)
original_skill_dir = Path(original_path)
if (
original_skill_dir.is_symlink()
or original_skill_dir.is_file()
):
original_skill_dir.unlink()
elif original_skill_dir.exists():
shutil.rmtree(original_skill_dir)
original_skill_dir.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(
backup_skill_dir,
original_skill_dir,
symlinks=True,
)
# Remove empty artifact directories that did not exist at
# the destructive boundary. Do this after skill cleanup and
# restoration so newly created skills roots and their
# project-local parents can also be removed exactly.
for command_dir in sorted(
new_command_dirs_absent_before_update,
key=lambda path: len(path.parts),
reverse=True,
):
if command_dir.is_dir() and not command_dir.is_symlink():
try:
command_dir.rmdir()
except OSError:
# Preserve any non-empty directory: other
# content may belong to the user.
pass
# Restore metadata in extensions.yml (hooks and installed list).
# Only run if backup step 4 was reached (backup_hooks is not None);
@@ -1491,10 +2090,26 @@ def extension_update(
if backup_registry_entry:
manager.registry.restore(extension_id, backup_registry_entry)
# Backup cleanup is post-rollback housekeeping. A locked
# file (notably on Windows) must not turn successfully
# restored state into a contradictory "Rollback failed".
cleanup_error = None
if backup_created_by_attempt and backup_base.exists():
try:
shutil.rmtree(backup_base)
except OSError as error:
cleanup_error = error
console.print(" [green]✓[/green] Rollback successful")
# Clean up backup directory only on successful rollback
if backup_base.exists():
shutil.rmtree(backup_base)
if cleanup_error is not None:
console.print(
" [yellow]Warning:[/yellow] Could not fully "
"remove rollback backup: "
f"{_escape_markup(str(cleanup_error))}"
)
console.print(
" [dim]Backup may remain at: "
f"{_escape_markup(str(backup_base))}[/dim]"
)
except Exception as rollback_error:
console.print(f" [red]✗[/red] Rollback failed: {_escape_markup(str(rollback_error))}")
console.print(f" [dim]Backup preserved at: {_escape_markup(str(backup_base))}[/dim]")

View File

@@ -12,7 +12,6 @@ import json
import hashlib
import os
import tempfile
import zipfile
import shutil
from dataclasses import dataclass
from pathlib import Path
@@ -27,6 +26,13 @@ import yaml
from packaging import version as pkg_version
from packaging.specifiers import SpecifierSet, InvalidSpecifier
from .._download_security import (
MAX_JSON_CATALOG_BYTES,
build_safe_download_path,
is_https_or_localhost_http,
read_response_limited,
safe_extract_zip,
)
from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
from .._init_options import (
MISSING_INIT_OPTIONS_FILE,
@@ -3523,18 +3529,7 @@ class PresetManager:
with tempfile.TemporaryDirectory() as tmpdir:
temp_path = Path(tmpdir)
with zipfile.ZipFile(zip_path, 'r') as zf:
temp_path_resolved = temp_path.resolve()
for member in zf.namelist():
member_path = (temp_path / member).resolve()
try:
member_path.relative_to(temp_path_resolved)
except ValueError:
raise PresetValidationError(
f"Unsafe path in ZIP archive: {member} "
"(potential path traversal)"
)
zf.extractall(temp_path)
safe_extract_zip(zip_path, temp_path, error_type=PresetValidationError)
pack_dir = temp_path
manifest_path = pack_dir / "preset.yml"
@@ -4270,7 +4265,14 @@ class PresetCatalog:
final_url = response.geturl()
if final_url != entry.url:
self._validate_catalog_url(final_url)
catalog_data = json.loads(response.read())
catalog_data = json.loads(
read_response_limited(
response,
max_bytes=MAX_JSON_CATALOG_BYTES,
error_type=PresetError,
label=f"preset catalog {entry.url}",
)
)
self._validate_catalog_payload(catalog_data, entry.url)
@@ -4432,7 +4434,14 @@ class PresetCatalog:
final_url = response.geturl()
if final_url != catalog_url:
self._validate_catalog_url(final_url)
catalog_data = json.loads(response.read())
catalog_data = json.loads(
read_response_limited(
response,
max_bytes=MAX_JSON_CATALOG_BYTES,
error_type=PresetError,
label=f"preset catalog {catalog_url}",
)
)
# Validate catalog structure. Reuses the same helper as
# ``_fetch_single_catalog`` so all three branches (root type,
@@ -4593,6 +4602,10 @@ class PresetCatalog:
raise PresetError(
f"Preset '{pack_id}' has no download URL"
)
if not isinstance(download_url, str):
raise PresetError(
f"Preset download URL is malformed: {download_url}"
)
from urllib.parse import urlparse
@@ -4605,25 +4618,32 @@ class PresetCatalog:
try:
parsed = urlparse(download_url)
hostname = parsed.hostname
parsed.port
except ValueError:
raise PresetError(
f"Preset download URL is malformed: {download_url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (
parsed.scheme == "http" and is_localhost
):
if not hostname:
raise PresetError(
f"Preset download URL is malformed: {download_url}"
)
if not is_https_or_localhost_http(download_url):
raise PresetError(
f"Preset download URL must use HTTPS: {download_url}"
)
if target_dir is None:
target_dir = self.cache_dir / "downloads"
target_dir.mkdir(parents=True, exist_ok=True)
target_dir = Path(target_dir)
version = pack_info.get("version", "unknown")
zip_filename = f"{pack_id}-{version}.zip"
zip_path = target_dir / zip_filename
zip_path = build_safe_download_path(
target_dir,
pack_id,
version,
error_type=PresetError,
label="preset",
)
target_dir.mkdir(parents=True, exist_ok=True)
extra_headers = None
resolved_download_url = self._resolve_github_release_asset_api_url(download_url)
@@ -4633,7 +4653,11 @@ class PresetCatalog:
try:
with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response:
zip_data = response.read()
zip_data = read_response_limited(
response,
error_type=PresetError,
label=f"preset '{pack_id}' download",
)
verify_archive_sha256(
zip_data, pack_info.get("sha256"), pack_id, PresetError

View File

@@ -19,6 +19,7 @@ from .._console import console
from .._download_security import (
is_https_or_localhost_http,
is_safe_download_redirect,
read_response_limited,
)
preset_app = typer.Typer(
@@ -126,15 +127,15 @@ def preset_add(
if not is_https_or_localhost_http(from_url):
console.print(
"[red]Error:[/red] URL must use HTTPS with a hostname, "
"or HTTP for localhost/loopback."
"[red]Error:[/red] URL must use HTTPS with a hostname and be "
"a valid URL with a host. HTTP is only allowed for localhost, "
"127.0.0.1, and ::1."
)
raise typer.Exit(1)
console.print(f"Installing preset from [cyan]{_escape_markup(from_url)}[/cyan]...")
import urllib.error
import tempfile
import shutil
with tempfile.TemporaryDirectory() as tmpdir:
zip_path = Path(tmpdir) / "preset.zip"
@@ -162,16 +163,21 @@ def preset_add(
console.print(
"[red]Error:[/red] Preset URL redirected to a disallowed URL: "
f"{final_url}. Redirect targets must use HTTPS with a hostname, "
"or HTTP for localhost/loopback."
"or HTTP for localhost (127.0.0.1, ::1)."
)
raise typer.Exit(1)
with zip_path.open("wb") as output:
try:
shutil.copyfileobj(response, output)
except TypeError:
output.write(response.read())
except urllib.error.URLError as e:
console.print(f"[red]Error:[/red] Failed to download: {_escape_markup(str(e))}")
zip_path.write_bytes(
read_response_limited(
response,
error_type=PresetError,
label=f"preset {from_url}",
)
)
except (urllib.error.URLError, PresetError) as e:
console.print(
f"[red]Error:[/red] Failed to download: "
f"{_escape_markup(str(e))}"
)
raise typer.Exit(1)
manifest = manager.install_from_zip(zip_path, speckit_version, priority)

View File

@@ -12,7 +12,7 @@ import json
import os
import re
import sys
from pathlib import Path
from pathlib import Path, PurePosixPath
from typing import Any
import typer
@@ -401,6 +401,12 @@ def _reject_insecure_download_redirect(old_url: str, new_url: str) -> None:
# a ceiling any legitimate workflow definition should ever approach.
_MAX_WORKFLOW_YAML_BYTES = 5 * 1024 * 1024 # 5 MiB
_DOWNLOAD_CHUNK_SIZE = 65536
# Custom step packages contain executable Python, metadata, and optional helper
# files downloaded one-by-one rather than as an archive. Mirror the archive
# ceilings so a catalog cannot turn individually valid files into an unbounded
# aggregate download.
_MAX_STEP_PACKAGE_FILES = 512
_MAX_STEP_PACKAGE_BYTES = 50 * 1024 * 1024 # 50 MiB
def _read_response_within_limit(response, max_bytes: int | None = None) -> bytes:
@@ -2658,14 +2664,39 @@ def workflow_step_add(
)
raise typer.Exit(1)
step_yml_url = info.get("step_yml_url") or info.get("url")
if not step_yml_url:
declared_step_yml_url = info.get("step_yml_url")
if declared_step_yml_url is not None and not isinstance(
declared_step_yml_url, str
):
console.print(
f"[red]Error:[/red] Catalog entry for '{step_id}' has a malformed "
"step.yml URL; expected a non-empty string"
)
raise typer.Exit(1)
step_yml_url = declared_step_yml_url or info.get("url")
if step_yml_url is None or (
isinstance(step_yml_url, str) and not step_yml_url.strip()
):
console.print(f"[red]Error:[/red] Catalog entry for '{step_id}' has no URL")
raise typer.Exit(1)
if not isinstance(step_yml_url, str):
console.print(
f"[red]Error:[/red] Catalog entry for '{step_id}' has a malformed "
"step.yml URL; expected a non-empty string"
)
raise typer.Exit(1)
# Derive __init__.py URL: replace trailing step.yml with __init__.py
# or use explicit init_url if provided.
init_url = info.get("init_url")
if init_url is not None and (
not isinstance(init_url, str) or not init_url.strip()
):
console.print(
f"[red]Error:[/red] Catalog entry for '{step_id}' has a malformed "
"__init__.py URL; expected a non-empty string"
)
raise typer.Exit(1)
if not init_url:
if step_yml_url.endswith("step.yml"):
init_url = step_yml_url[: -len("step.yml")] + "__init__.py"
@@ -2676,6 +2707,41 @@ def workflow_step_add(
)
raise typer.Exit(1)
# Preflight the declared file count before creating a staging directory or
# issuing any request. The two required files are always part of the package;
# duplicate declarations for them in extra_files are ignored below and do
# not count twice.
extra_files = info.get("extra_files")
if extra_files is not None and not isinstance(extra_files, dict):
console.print(
"[yellow]Warning:[/yellow] Catalog entry 'extra_files' is not a mapping; "
"additional package files will not be downloaded."
)
extra_files = {}
def _is_required_package_file(rel_path: object) -> bool:
"""Match portable path/case aliases of the two required package files."""
if not isinstance(rel_path, str):
return False
parts = PurePosixPath(rel_path.replace("\\", "/")).parts
return len(parts) == 1 and parts[0].casefold() in {
"step.yml",
"__init__.py",
}
declared_extra_count = sum(
1
for rel_path in (extra_files or {})
if not _is_required_package_file(rel_path)
)
package_file_count = 2 + declared_extra_count
if package_file_count > _MAX_STEP_PACKAGE_FILES:
console.print(
f"[red]Error:[/red] Step package declares {package_file_count} files, "
f"exceeding the {_MAX_STEP_PACKAGE_FILES}-file limit"
)
raise typer.Exit(1)
from specify_cli.authentication.http import open_url as _open_url
def _safe_fetch(url: str) -> bytes:
@@ -2732,6 +2798,14 @@ def workflow_step_add(
console.print(f"[red]Error:[/red] Failed to download step files: {exc}")
raise typer.Exit(1)
package_bytes = len(step_yml_content) + len(init_py_content)
if package_bytes > _MAX_STEP_PACKAGE_BYTES:
console.print(
f"[red]Error:[/red] Step package exceeds the "
f"{_MAX_STEP_PACKAGE_BYTES}-byte total size limit"
)
raise typer.Exit(1)
# Validate step.yml
try:
import yaml as _yaml
@@ -2776,13 +2850,6 @@ def workflow_step_add(
# relative-path → URL. step.yml and __init__.py are ignored here (already
# written). Paths are validated to stay within the step package directory to
# prevent path-traversal attacks.
extra_files = info.get("extra_files")
if extra_files is not None and not isinstance(extra_files, dict):
console.print(
"[yellow]Warning:[/yellow] Catalog entry 'extra_files' is not a mapping; "
"additional package files will not be downloaded."
)
extra_files = {}
for rel_path, file_url in (extra_files or {}).items():
if not isinstance(rel_path, str) or not rel_path.strip():
console.print(
@@ -2790,7 +2857,7 @@ def workflow_step_add(
"empty or non-string path key"
)
raise typer.Exit(1)
if rel_path in ("step.yml", "__init__.py"):
if _is_required_package_file(rel_path):
continue # already written above
# Reject dot-path segments ('', '.', '..') that would refer to the
# package directory itself (IsADirectoryError) or escape it.
@@ -2826,6 +2893,13 @@ def workflow_step_add(
f"[red]Error:[/red] Failed to download extra file '{rel_path}': {exc}"
)
raise typer.Exit(1)
package_bytes += len(file_content)
if package_bytes > _MAX_STEP_PACKAGE_BYTES:
console.print(
f"[red]Error:[/red] Step package exceeds the "
f"{_MAX_STEP_PACKAGE_BYTES}-byte total size limit"
)
raise typer.Exit(1)
try:
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(file_content)

View File

@@ -22,6 +22,8 @@ from typing import Any
import yaml
from .._download_security import MAX_JSON_CATALOG_BYTES, read_response_limited
# ---------------------------------------------------------------------------
# Errors
@@ -308,7 +310,8 @@ class WorkflowCatalog:
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
_ = parsed.port
except (TypeError, ValueError):
raise WorkflowValidationError(
f"Catalog URL is malformed: {url}"
) from None
@@ -505,7 +508,8 @@ class WorkflowCatalog:
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
_ = parsed.port
except (TypeError, ValueError):
raise WorkflowCatalogError(
f"Refusing to fetch catalog from malformed URL: {url}"
) from None
@@ -538,7 +542,14 @@ class WorkflowCatalog:
entry.url, timeout=30, redirect_validator=_validate_redirect
) as resp:
_validate_catalog_url(resp.geturl())
data = json.loads(resp.read().decode("utf-8"))
data = json.loads(
read_response_limited(
resp,
max_bytes=MAX_JSON_CATALOG_BYTES,
error_type=WorkflowCatalogError,
label="workflow catalog",
).decode("utf-8")
)
except Exception as exc:
# Fall back to cache if available
if cache_file.exists():
@@ -982,7 +993,8 @@ class StepCatalog:
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
_ = parsed.port
except (TypeError, ValueError):
raise StepValidationError(
f"Catalog URL is malformed: {url}"
) from None
@@ -1178,7 +1190,8 @@ class StepCatalog:
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
_ = parsed.port
except (TypeError, ValueError):
raise StepCatalogError(
f"Refusing to fetch catalog from malformed URL: {url}"
) from None
@@ -1211,7 +1224,14 @@ class StepCatalog:
entry.url, timeout=30, redirect_validator=_validate_redirect
) as resp:
_validate_url(resp.geturl())
data = json.loads(resp.read().decode("utf-8"))
data = json.loads(
read_response_limited(
resp,
max_bytes=MAX_JSON_CATALOG_BYTES,
error_type=StepCatalogError,
label="step catalog",
).decode("utf-8")
)
except Exception as exc:
if cache_safe and cache_file.exists():
try:

View File

@@ -247,6 +247,25 @@ def test_catalog_entry_rejects_non_boolean_verified():
CatalogEntry.from_dict(data)
def test_catalog_entry_preserves_sha256_through_provenance():
digest = "a" * 64
payload = catalog_payload(
{"demo": catalog_entry_dict("demo", sha256=f"sha256:{digest}")}
)
entry = load_catalog_payload(payload)["demo"]
source = CatalogSource(
id="team",
url="https://example.com/catalog.json",
priority=10,
install_policy=InstallPolicy.INSTALL_ALLOWED,
scope=Scope.PROJECT,
)
assert entry.sha256 == f"sha256:{digest}"
assert entry.with_provenance(source).sha256 == f"sha256:{digest}"
def test_load_payload_rejects_id_key_mismatch():
# The enclosing key is authoritative; an entry whose own id disagrees with
# the key must be rejected so a catalog can't list a spoofed/unresolvable id.

View File

@@ -7,7 +7,9 @@ proving the real in-process primitive dispatch (T044) works without a network.
from __future__ import annotations
import os
import zipfile
from pathlib import Path
from unittest.mock import patch
import pytest
import yaml
@@ -171,3 +173,62 @@ def test_download_manifest_rejects_non_https_url_even_offline(tmp_path: Path):
)
with pytest.raises(BundlerError, match="HTTPS"):
_download_manifest(resolved, offline=True)
def test_local_zip_uses_bounded_archive_open(tmp_path: Path):
artifact = tmp_path / "too-many-entries.zip"
with zipfile.ZipFile(artifact, "w") as archive:
archive.writestr("bundle.yml", yaml.safe_dump(valid_manifest_dict()))
for index in range(512):
archive.writestr(f"assets/{index}.txt", "")
with pytest.raises(BundlerError, match="too many entries"):
_local_manifest_source(str(artifact))
def test_invalid_local_manifest_is_rejected_before_project_init(
tmp_path: Path,
monkeypatch,
):
bundle_dir = tmp_path / "invalid-bundle"
data = valid_manifest_dict()
data["bundle"]["author"] = ""
write_manifest(bundle_dir, data)
empty_cwd = tmp_path / "empty"
empty_cwd.mkdir()
monkeypatch.chdir(empty_cwd)
runner = CliRunner()
with patch("specify_cli.commands.bundle._run_init") as run_init:
result = runner.invoke(
app,
["bundle", "install", str(bundle_dir), "--offline"],
)
assert result.exit_code == 1
assert "Missing required field: bundle.author" in result.output
run_init.assert_not_called()
def test_incompatible_local_manifest_is_rejected_before_project_init(
tmp_path: Path,
monkeypatch,
):
bundle_dir = tmp_path / "incompatible-bundle"
data = valid_manifest_dict()
data["requires"]["speckit_version"] = ">=999.0.0"
write_manifest(bundle_dir, data)
empty_cwd = tmp_path / "empty"
empty_cwd.mkdir()
monkeypatch.chdir(empty_cwd)
runner = CliRunner()
with patch("specify_cli.commands.bundle._run_init") as run_init:
result = runner.invoke(
app,
["bundle", "install", str(bundle_dir), "--offline"],
)
assert result.exit_code == 1
assert "requires Spec Kit >=999.0.0" in result.output
run_init.assert_not_called()

View File

@@ -1,15 +1,24 @@
"""Tests for bounded HTTP download helpers."""
"""Tests for bounded download and ZIP extraction helpers."""
from __future__ import annotations
import io
import stat
import struct
import weakref
import zipfile
import zlib
import pytest
from specify_cli._download_security import (
MAX_ZIP_CENTRAL_DIRECTORY_BYTES,
build_safe_download_path,
is_https_or_localhost_http,
is_loopback_url,
read_response_limited,
read_zip_member_limited,
safe_extract_zip,
)
@@ -112,8 +121,6 @@ class _Response:
def __init__(self, data: bytes, *, chunk: int | None = None):
self.data = data
self.pos = 0
# When set, never return more than *chunk* bytes per call even if more is
# requested - simulates short reads (e.g. chunked transfer encoding).
self.chunk = chunk
def read(self, size: int = -1) -> bytes:
@@ -160,6 +167,55 @@ class _OneByteResponse:
)
return chunk
def __enter__(self):
return self
def __exit__(self, _exc_type, _exc, _tb):
return False
class _CustomZipError(ValueError):
pass
class _ExplodingResponse:
def read(self, _size: int = -1) -> bytes:
raise zlib.error("corrupt compressed data")
def __enter__(self):
return self
def __exit__(self, _exc_type, _exc, _tb):
return False
class _FakeZipArchive:
def __init__(
self,
response,
*,
filename: str = "extension.yml",
file_size: int = 0,
):
self.response = response
self.info = zipfile.ZipInfo(filename)
self.info.file_size = file_size
def __enter__(self):
return self
def __exit__(self, _exc_type, _exc, _tb):
return False
def getinfo(self, _name):
return self.info
def infolist(self):
return [self.info]
def open(self, _member, _mode="r"):
return self.response
def test_read_response_limited_rejects_oversized_download():
with pytest.raises(ValueError, match="exceeds maximum size"):
@@ -171,9 +227,6 @@ def test_read_response_limited_returns_full_body_within_limit():
def test_read_response_limited_enforces_bound_under_short_reads():
# A server that streams more than max_bytes total while every read() returns
# fewer bytes than requested (chunked encoding) must still be rejected - a
# single read(max_bytes + 1) could be fooled, the accumulating loop cannot.
response = _Response(b"x" * 100, chunk=8)
with pytest.raises(ValueError, match="exceeds maximum size"):
read_response_limited(response, max_bytes=16)
@@ -225,3 +278,794 @@ def test_read_response_limited_rejects_first_byte_at_zero_limit():
max_bytes=0,
error_type=_CustomLimitError,
)
def test_read_response_limited_escapes_control_characters_in_label():
with pytest.raises(ValueError) as exc_info:
read_response_limited(
_Response(b"x"),
max_bytes=0,
label="bad\x1b[2J download",
)
assert "\x1b" not in str(exc_info.value)
assert "\\x1b" in str(exc_info.value)
@pytest.mark.parametrize(
"identifier",
[
"../outside",
"..\\outside",
"a" * 256,
"delete\x7f",
"csi\x9b[2J",
"\ud800",
],
)
def test_build_safe_download_path_rejects_nonportable_identifiers(
tmp_path, identifier
):
with pytest.raises(ValueError, match="Unsafe archive download filename"):
build_safe_download_path(
tmp_path,
identifier,
"1.0.0",
)
@pytest.mark.parametrize(
"member_name",
[
"../evil.txt",
"nested/../../evil.txt",
"nested\\..\\evil.txt",
"C:\\Windows\\evil.txt",
"C:drive-relative.txt",
],
)
def test_safe_extract_zip_rejects_traversal(tmp_path, member_name):
zip_path = tmp_path / "bad.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(member_name, "nope")
with pytest.raises(ValueError, match="Unsafe path"):
safe_extract_zip(zip_path, tmp_path / "out")
@pytest.mark.parametrize("member_name", [".", "./file.txt", "nested/./file.txt", "nested//file.txt"])
def test_safe_extract_zip_rejects_dot_path_segments(tmp_path, member_name):
zip_path = tmp_path / "bad.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(member_name, "nope")
with pytest.raises(_CustomZipError, match="Unsafe path"):
safe_extract_zip(zip_path, tmp_path / "out", error_type=_CustomZipError)
def test_safe_extract_zip_rejects_symlinks(tmp_path):
zip_path = tmp_path / "bad.zip"
info = zipfile.ZipInfo("link")
info.external_attr = (stat.S_IFLNK | 0o777) << 16
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(info, "target")
with pytest.raises(ValueError, match="Unsafe symlink"):
safe_extract_zip(zip_path, tmp_path / "out")
def test_safe_extract_zip_rejects_symlink_without_partial_extraction(tmp_path):
zip_path = tmp_path / "mixed.zip"
link = zipfile.ZipInfo("evil-link")
link.external_attr = (stat.S_IFLNK | 0o777) << 16
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("safe/first.txt", "hello")
zf.writestr(link, "target")
zf.writestr("safe/second.txt", "world")
out_dir = tmp_path / "out"
with pytest.raises(ValueError, match="Unsafe symlink"):
safe_extract_zip(zip_path, out_dir)
assert not out_dir.exists() or not any(out_dir.rglob("*"))
def test_safe_extract_zip_rejects_oversized_member(tmp_path):
zip_path = tmp_path / "bad.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("big.txt", "abcde")
with pytest.raises(ValueError, match="exceeds maximum size"):
safe_extract_zip(zip_path, tmp_path / "out", max_member_bytes=4)
def test_safe_extract_zip_rejects_too_many_entries(tmp_path):
zip_path = tmp_path / "bad.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("one.txt", "1")
zf.writestr("two.txt", "2")
with pytest.raises(ValueError, match="too many entries"):
safe_extract_zip(zip_path, tmp_path / "out", max_entries=1)
def _legacy_zip_eocd(
*,
entries: int,
central_directory_size: int,
central_directory_offset: int = 0,
comment_size: int = 0,
) -> bytes:
return struct.pack(
"<4s4H2LH",
b"PK\x05\x06",
0,
0,
entries,
entries,
central_directory_size,
central_directory_offset,
comment_size,
)
def test_safe_extract_zip_preflights_declared_entry_count(tmp_path, monkeypatch):
zip_path = tmp_path / "too-many.zip"
zip_path.write_bytes(
_legacy_zip_eocd(entries=513, central_directory_size=0)
)
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="too many entries"):
safe_extract_zip(zip_path, tmp_path / "out")
def test_safe_extract_zip_preflights_actual_entry_count_when_eocd_lies(
tmp_path, monkeypatch
):
central_header = b"PK\x01\x02" + b"\x00" * 42
central_directory = central_header * 513
zip_path = tmp_path / "lying-count.zip"
zip_path.write_bytes(
central_directory
+ _legacy_zip_eocd(
entries=1,
central_directory_size=len(central_directory),
)
)
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="too many entries"):
safe_extract_zip(zip_path, tmp_path / "out")
def test_safe_extract_zip_rejects_truncated_last_eocd_comment(
tmp_path, monkeypatch
):
trailing_eocd = _legacy_zip_eocd(
entries=0,
central_directory_size=0,
comment_size=1,
)
zip_path = tmp_path / "ambiguous-eocd.zip"
zip_path.write_bytes(
_legacy_zip_eocd(
entries=0,
central_directory_size=0,
comment_size=len(trailing_eocd),
)
+ trailing_eocd
)
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="Invalid ZIP archive"):
safe_extract_zip(zip_path, tmp_path / "out")
def test_safe_extract_zip_rejects_zip64_before_zipfile_construction(
tmp_path, monkeypatch
):
zip64_eocd = struct.pack(
"<4sQ2H2L4Q",
b"PK\x06\x06",
44,
45,
45,
0,
0,
0,
0,
0,
0,
)
zip64_locator = struct.pack(
"<4sLQL",
b"PK\x06\x07",
0,
0,
1,
)
zip_path = tmp_path / "zip64.zip"
zip_path.write_bytes(
zip64_eocd
+ zip64_locator
+ _legacy_zip_eocd(
entries=0xFFFF,
central_directory_size=0xFFFFFFFF,
central_directory_offset=0xFFFFFFFF,
)
)
with zipfile.ZipFile(zip_path) as zf:
assert zf.namelist() == []
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="ZIP64"):
safe_extract_zip(zip_path, tmp_path / "out")
@pytest.mark.parametrize(
"indicator",
[
"central-sizes",
"central-offset",
"central-disk",
"local-sizes",
],
)
def test_safe_extract_zip_rejects_entry_zip64_before_zipfile_construction(
tmp_path, monkeypatch, indicator
):
contents = b"contents"
if indicator == "central-offset":
zip64_payload = struct.pack("<Q", 0)
elif indicator == "central-disk":
zip64_payload = struct.pack("<L", 0)
else:
zip64_payload = struct.pack("<QQ", len(contents), len(contents))
info = zipfile.ZipInfo("file.txt")
info.extra = struct.pack("<HH", 0xCAFE, len(zip64_payload)) + zip64_payload
zip_path = tmp_path / f"{indicator}.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(info, contents)
archive = bytearray(zip_path.read_bytes())
local_header = archive.index(b"PK\x03\x04")
central_header = archive.index(b"PK\x01\x02")
if indicator.startswith("central"):
filename_size = struct.unpack_from("<H", archive, central_header + 28)[0]
extra_offset = central_header + 46 + filename_size
struct.pack_into("<H", archive, extra_offset, 0x0001)
if indicator == "central-sizes":
struct.pack_into("<LL", archive, central_header + 20, 0xFFFFFFFF, 0xFFFFFFFF)
elif indicator == "central-offset":
struct.pack_into("<L", archive, central_header + 42, 0xFFFFFFFF)
else:
struct.pack_into("<H", archive, central_header + 34, 0xFFFF)
else:
filename_size = struct.unpack_from("<H", archive, local_header + 26)[0]
extra_offset = local_header + 30 + filename_size
struct.pack_into("<H", archive, extra_offset, 0x0001)
struct.pack_into("<LL", archive, local_header + 18, 0xFFFFFFFF, 0xFFFFFFFF)
zip_path.write_bytes(archive)
# The stdlib accepts each hybrid ZIP64 entry. The bounded opener must reject
# it during preflight, before handing the archive to ZipFile.
with zipfile.ZipFile(zip_path) as zf:
assert zf.read("file.txt") == contents
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="ZIP64"):
safe_extract_zip(zip_path, tmp_path / "out")
@pytest.mark.parametrize("header_kind", ["central", "local"])
def test_safe_extract_zip_rejects_zip64_extra_without_sentinel_before_zipfile(
tmp_path, monkeypatch, header_kind
):
info = zipfile.ZipInfo("file.txt")
info.extra = struct.pack("<HH", 0xCAFE, 0)
zip_path = tmp_path / f"{header_kind}-extra.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(info, b"contents")
archive = bytearray(zip_path.read_bytes())
if header_kind == "central":
header_offset = archive.index(b"PK\x01\x02")
filename_size = struct.unpack_from("<H", archive, header_offset + 28)[0]
extra_offset = header_offset + 46 + filename_size
else:
header_offset = archive.index(b"PK\x03\x04")
filename_size = struct.unpack_from("<H", archive, header_offset + 26)[0]
extra_offset = header_offset + 30 + filename_size
struct.pack_into("<H", archive, extra_offset, 0x0001)
zip_path.write_bytes(archive)
with zipfile.ZipFile(zip_path) as zf:
assert zf.read("file.txt") == b"contents"
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="ZIP64"):
safe_extract_zip(zip_path, tmp_path / "out")
def test_safe_extract_zip_rejects_force_zip64_local_header_before_zipfile(
tmp_path, monkeypatch
):
zip_path = tmp_path / "forced-local-zip64.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
with zf.open("file.txt", "w", force_zip64=True) as target:
target.write(b"contents")
# For a small streamed member, ZipFile leaves the central directory and
# EOCD legacy-sized while placing ZIP64 sentinels and extra data locally.
with zipfile.ZipFile(zip_path) as zf:
assert zf.read("file.txt") == b"contents"
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="ZIP64"):
safe_extract_zip(zip_path, tmp_path / "out")
@pytest.mark.parametrize("extract_version", [45, 46])
@pytest.mark.parametrize("visible_version", ["central", "local"])
def test_safe_extract_zip_rejects_masked_zip64_data_descriptor_version_or_newer(
tmp_path, monkeypatch, visible_version, extract_version
):
class UnseekableBuffer(io.BytesIO):
def seek(self, *_args, **_kwargs):
raise io.UnsupportedOperation
stream = UnseekableBuffer()
with zipfile.ZipFile(stream, "w") as zf:
with zf.open("file.txt", "w", force_zip64=True) as target:
target.write(b"contents")
archive = bytearray(stream.getvalue())
local_header = archive.index(b"PK\x03\x04")
central_header = archive.index(b"PK\x01\x02")
assert struct.unpack_from("<H", archive, local_header + 6)[0] & 0x0008
assert struct.unpack_from("<H", archive, local_header + 4)[0] == 45
assert struct.unpack_from("<H", archive, central_header + 6)[0] == 45
# Hide the local size sentinels and ZIP64 extra ID while retaining the
# 64-bit data descriptor emitted by ZipFile. Leave a ZIP64-or-newer
# extractor version visible in exactly one header to exercise both
# preflight checks.
struct.pack_into("<LL", archive, local_header + 18, 0, 0)
filename_size = struct.unpack_from("<H", archive, local_header + 26)[0]
local_extra = local_header + 30 + filename_size
struct.pack_into("<H", archive, local_extra, 0xCAFE)
struct.pack_into("<H", archive, local_header + 4, 20)
struct.pack_into("<H", archive, central_header + 6, 20)
if visible_version == "central":
struct.pack_into("<H", archive, central_header + 6, extract_version)
else:
struct.pack_into("<H", archive, local_header + 4, extract_version)
zip_path = tmp_path / f"masked-{visible_version}-v{extract_version}.zip"
zip_path.write_bytes(archive)
with zipfile.ZipFile(zip_path) as zf:
assert zf.read("file.txt") == b"contents"
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="extractor version 4.5 or newer"):
safe_extract_zip(zip_path, tmp_path / "out")
@pytest.mark.parametrize("compression", [zipfile.ZIP_BZIP2, zipfile.ZIP_LZMA])
def test_safe_extract_zip_rejects_unbounded_compression_before_zipfile(
tmp_path, monkeypatch, compression
):
zip_path = tmp_path / f"unsupported-{compression}.zip"
with zipfile.ZipFile(zip_path, "w", compression=compression) as zf:
zf.writestr("bomb.txt", b"A" * (1024 * 1024))
# Lie about the output size. For BZIP2/LZMA, ZipExtFile materializes the
# whole decompressor result before slicing it to the requested length.
archive = bytearray(zip_path.read_bytes())
central_header = archive.index(b"PK\x01\x02")
struct.pack_into("<L", archive, central_header + 24, 1)
zip_path.write_bytes(archive)
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="supports only STORED and DEFLATED"):
safe_extract_zip(zip_path, tmp_path / "out")
@pytest.mark.parametrize(
"compression",
[zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED],
)
def test_safe_extract_zip_accepts_bounded_compression_methods(
tmp_path, compression
):
zip_path = tmp_path / f"supported-{compression}.zip"
with zipfile.ZipFile(zip_path, "w", compression=compression) as zf:
zf.writestr("file.txt", b"contents")
out_dir = tmp_path / "out"
safe_extract_zip(zip_path, out_dir)
assert (out_dir / "file.txt").read_bytes() == b"contents"
def test_safe_extract_zip_accepts_archive_with_prepended_data(tmp_path):
zip_path = tmp_path / "prefixed.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("file.txt", "contents")
zip_path.write_bytes(b"launcher-prefix" + zip_path.read_bytes())
out_dir = tmp_path / "out"
safe_extract_zip(zip_path, out_dir)
assert (out_dir / "file.txt").read_text(encoding="utf-8") == "contents"
def test_safe_extract_zip_rejects_central_entry_from_another_disk(tmp_path):
zip_path = tmp_path / "multi-disk-entry.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("file.txt", "contents")
archive = bytearray(zip_path.read_bytes())
central_header = archive.index(b"PK\x01\x02")
struct.pack_into("<H", archive, central_header + 34, 1)
zip_path.write_bytes(archive)
with pytest.raises(ValueError, match="Multi-disk"):
safe_extract_zip(zip_path, tmp_path / "out")
def test_safe_extract_zip_caps_central_directory_before_zipfile(
tmp_path, monkeypatch
):
zip_path = tmp_path / "large-directory.zip"
zip_path.write_bytes(
_legacy_zip_eocd(
entries=1,
central_directory_size=MAX_ZIP_CENTRAL_DIRECTORY_BYTES + 1,
)
)
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="central directory exceeds"):
safe_extract_zip(zip_path, tmp_path / "out")
def test_safe_extract_zip_rejects_total_uncompressed_size(tmp_path):
zip_path = tmp_path / "bad.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("one.txt", "123")
zf.writestr("two.txt", "456")
with pytest.raises(ValueError, match="maximum uncompressed size"):
safe_extract_zip(zip_path, tmp_path / "out", max_total_bytes=5)
def test_safe_extract_zip_wraps_bad_zip_file(tmp_path):
zip_path = tmp_path / "bad.zip"
zip_path.write_bytes(b"not a zip archive")
with pytest.raises(_CustomZipError, match="Invalid ZIP archive"):
safe_extract_zip(zip_path, tmp_path / "out", error_type=_CustomZipError)
def test_safe_extract_zip_wraps_unsupported_zip_version(tmp_path):
zip_path = tmp_path / "unsupported.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("file.txt", "contents")
archive = bytearray(zip_path.read_bytes())
central_header = archive.index(b"PK\x01\x02")
struct.pack_into("<H", archive, central_header + 6, 99)
zip_path.write_bytes(archive)
with pytest.raises(
_CustomZipError,
match="extractor version 4.5 or newer",
):
safe_extract_zip(zip_path, tmp_path / "out", error_type=_CustomZipError)
def test_read_zip_member_limited_returns_member_within_limit(tmp_path):
zip_path = tmp_path / "ok.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("extension.yml", "extension:\n id: demo\n")
with zipfile.ZipFile(zip_path, "r") as zf:
data = read_zip_member_limited(zf, "extension.yml")
assert data == b"extension:\n id: demo\n"
def test_read_zip_member_limited_does_not_retain_short_read_fragments():
response = _OneByteResponse(64)
archive = _FakeZipArchive(response, file_size=64)
assert (
read_zip_member_limited(archive, "extension.yml", max_bytes=64)
== b"x" * 64
)
assert response.peak_live <= 2
@pytest.mark.parametrize("value", [None, "1", 1.5, True])
def test_read_zip_member_limited_rejects_non_integer_limits(value):
archive = _FakeZipArchive(_OneByteResponse(0))
with pytest.raises(TypeError, match="integer"):
read_zip_member_limited(archive, "extension.yml", max_bytes=value)
def test_read_zip_member_limited_rejects_negative_limit_without_opening():
archive = _FakeZipArchive(_OneByteResponse(0))
with pytest.raises(ValueError, match="non-negative"):
read_zip_member_limited(archive, "extension.yml", max_bytes=-1)
def test_read_zip_member_limited_rejects_oversized_member(tmp_path):
zip_path = tmp_path / "bomb.zip"
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("extension.yml", "a" * 5000)
with zipfile.ZipFile(zip_path, "r") as zf:
with pytest.raises(ValueError, match="exceeds maximum size"):
read_zip_member_limited(zf, "extension.yml", max_bytes=16)
def test_read_zip_member_limited_rejects_when_declared_size_is_too_small():
archive = _FakeZipArchive(_OneByteResponse(5), file_size=1)
with pytest.raises(ValueError, match="exceeds maximum size"):
read_zip_member_limited(
archive,
"extension.yml",
max_bytes=4,
)
def test_read_zip_member_limited_escapes_control_characters_in_errors():
member_name = "bad\x1b[2J/extension.yml"
archive = _FakeZipArchive(
_OneByteResponse(0),
filename=member_name,
file_size=5,
)
with pytest.raises(ValueError) as exc_info:
read_zip_member_limited(
archive,
member_name,
max_bytes=4,
)
assert "\x1b" not in str(exc_info.value)
assert "\\x1b" in str(exc_info.value)
def test_read_zip_member_limited_wraps_missing_member(tmp_path):
zip_path = tmp_path / "ok.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("other.txt", "x")
with zipfile.ZipFile(zip_path, "r") as zf:
with pytest.raises(_CustomZipError, match="ZIP member not found"):
read_zip_member_limited(zf, "extension.yml", error_type=_CustomZipError)
def test_read_zip_member_limited_wraps_decompression_errors():
archive = _FakeZipArchive(_ExplodingResponse(), file_size=1)
with pytest.raises(_CustomZipError, match="Failed to read ZIP member"):
read_zip_member_limited(
archive,
"extension.yml",
error_type=_CustomZipError,
)
@pytest.mark.parametrize(
"members",
[
[("nested\\file.txt", "first"), ("nested/file.txt", "second")],
[("node", "file"), ("node/child.txt", "child")],
[("node/child.txt", "child"), ("node", "file")],
[("Readme.txt", "first"), ("README.TXT", "second")],
[("caf\u00e9.txt", "first"), ("cafe\u0301.txt", "second")],
],
)
def test_safe_extract_zip_rejects_conflicting_paths_before_writing(
tmp_path, members
):
zip_path = tmp_path / "conflict.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
for name, contents in members:
zf.writestr(name, contents)
out_dir = tmp_path / "out"
with pytest.raises(ValueError, match="Conflicting path"):
safe_extract_zip(zip_path, out_dir)
assert not out_dir.exists() or not any(out_dir.rglob("*"))
@pytest.mark.parametrize(
"member_name",
[
"file::$DATA",
"file.",
"file ",
" leading.txt",
"NUL.txt",
"COM\u00b9.log",
"COM1 .txt",
"CONOUT$.log",
"nested/name?.txt",
"nested/control\u0001.txt",
"nested/delete\u007f.txt",
"nested/csi\u009b[2J.txt",
],
)
def test_safe_extract_zip_rejects_nonportable_member_names(tmp_path, member_name):
zip_path = tmp_path / "nonportable.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(member_name, "contents")
out_dir = tmp_path / "out"
with pytest.raises(ValueError, match="Unsafe path"):
safe_extract_zip(zip_path, out_dir)
assert not out_dir.exists() or not any(out_dir.rglob("*"))
@pytest.mark.parametrize(
"member_name",
[
"a" * 256,
"a/" * 2048 + "file.txt",
],
)
def test_safe_extract_zip_rejects_excessively_long_paths(tmp_path, member_name):
zip_path = tmp_path / "nonportable.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(member_name, "contents")
out_dir = tmp_path / "out"
with pytest.raises(ValueError, match="Unsafe path"):
safe_extract_zip(zip_path, out_dir)
assert not out_dir.exists() or not any(out_dir.rglob("*"))
@pytest.mark.parametrize(
("control_character", "escaped_character"),
[
("\x1b", "\\x1b"),
("\x7f", "\\x7f"),
("\x9b", "\\x9b"),
],
)
def test_safe_extract_zip_escapes_unicode_control_characters_in_errors(
tmp_path,
control_character,
escaped_character,
):
zip_path = tmp_path / "terminal-control.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(f"bad{control_character}[2J.txt", "contents")
with pytest.raises(ValueError) as exc_info:
safe_extract_zip(zip_path, tmp_path / "out")
assert control_character not in str(exc_info.value)
assert escaped_character in str(exc_info.value)
def test_safe_extract_zip_accepts_single_decomposed_unicode_name(tmp_path):
zip_path = tmp_path / "unicode.zip"
out_dir = tmp_path / "out"
decomposed_name = "cafe\u0301.txt"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(decomposed_name, "contents")
safe_extract_zip(zip_path, out_dir)
assert (out_dir / decomposed_name).read_text(encoding="utf-8") == "contents"
def test_safe_extract_zip_wraps_decompression_errors(tmp_path, monkeypatch):
zip_path = tmp_path / "corrupt.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("extension.yml", "x")
archive = _FakeZipArchive(_ExplodingResponse(), file_size=1)
monkeypatch.setattr(zipfile, "ZipFile", lambda *_args, **_kwargs: archive)
with pytest.raises(_CustomZipError, match="Failed to extract ZIP member"):
safe_extract_zip(
zip_path,
tmp_path / "out",
error_type=_CustomZipError,
)
def test_safe_extract_zip_enforces_actual_member_size(tmp_path, monkeypatch):
zip_path = tmp_path / "lying-size.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("extension.yml", "x")
archive = _FakeZipArchive(_OneByteResponse(5), file_size=1)
monkeypatch.setattr(zipfile, "ZipFile", lambda *_args, **_kwargs: archive)
with pytest.raises(ValueError, match="exceeds maximum size"):
safe_extract_zip(
zip_path,
tmp_path / "out",
max_member_bytes=4,
)
def test_safe_extract_zip_extracts_safe_archive(tmp_path):
zip_path = tmp_path / "ok.zip"
out_dir = tmp_path / "out"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("nested/file.txt", "hello")
safe_extract_zip(zip_path, out_dir)
assert (out_dir / "nested" / "file.txt").read_text(encoding="utf-8") == "hello"
def test_safe_extract_zip_treats_normalized_trailing_backslash_as_directory(tmp_path):
zip_path = tmp_path / "ok.zip"
out_dir = tmp_path / "out"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("nested\\", "")
zf.writestr("nested/file.txt", "hello")
safe_extract_zip(zip_path, out_dir)
assert (out_dir / "nested").is_dir()
assert (out_dir / "nested" / "file.txt").read_text(encoding="utf-8") == "hello"

View File

@@ -367,6 +367,16 @@ class TestExtensionManagerGetSkillsDir:
assert result is not None
assert result.is_dir()
def test_read_only_lookup_does_not_create_skills_dir(self, project_dir):
"""Backup discovery can resolve the target without mutating the project."""
_create_init_options(project_dir, ai="claude", ai_skills=True)
manager = ExtensionManager(project_dir)
result = manager._get_skills_dir(create=False)
assert result == project_dir / ".claude" / "skills"
assert not result.exists()
def test_returns_kimi_skills_dir_when_ai_skills_disabled(self, project_dir):
"""Kimi should still use its native skills dir when ai_skills is false."""
_create_init_options(project_dir, ai="kimi", ai_skills=False)
@@ -641,6 +651,27 @@ class TestExtensionSkillRegistration:
# The pre-existing one should NOT be in registered_skills (it was skipped)
assert "speckit-test-ext-hello" not in metadata["registered_skills"]
def test_existing_skill_directory_without_marker_is_not_modified(
self, skills_project, extension_dir
):
"""A user directory without SKILL.md must not become extension-owned."""
project_dir, skills_dir = skills_project
custom_dir = skills_dir / "speckit-test-ext-hello"
custom_dir.mkdir(parents=True)
support_file = custom_dir / "support.txt"
support_file.write_text("USER CONTENT", encoding="utf-8")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
assert support_file.read_text(encoding="utf-8") == "USER CONTENT"
assert not (custom_dir / "SKILL.md").exists()
metadata = manager.registry.get(manifest.id)
assert "speckit-test-ext-hello" not in metadata["registered_skills"]
assert "speckit-test-ext-world" in metadata["registered_skills"]
def test_dev_skill_symlink_refreshes_existing_cache(
self, skills_project, extension_dir, temp_dir
):
@@ -2189,6 +2220,7 @@ class TestExtensionSkillRegistration:
"""Flat registry provenance cannot own another project's home output."""
home = temp_dir / "home"
monkeypatch.setattr(Path, "home", lambda: home)
_create_init_options(project_dir, ai="hermes", ai_skills=True)
skill_name = "speckit-hermes-cleanup-hello"
skill_dir = home / ".hermes" / "skills" / skill_name
skill_dir.mkdir(parents=True)
@@ -2916,6 +2948,186 @@ class TestExtensionSkillRegistration:
class TestExtensionSkillUnregistration:
"""Test _unregister_extension_skills() on ExtensionManager."""
def test_read_only_discovery_includes_fallback_when_target_is_missing(
self, project_dir
):
"""Backup discovery covers removal's fallback without creating a target."""
_create_init_options(project_dir, ai="claude", ai_skills=True)
configured_dir = project_dir / ".claude" / "skills"
fallback_skill = (
project_dir
/ ".agents"
/ "skills"
/ "speckit-test-ext-hello"
)
fallback_skill.mkdir(parents=True)
frontmatter = yaml.safe_dump(
{
"name": fallback_skill.name,
"description": "Fallback skill",
"metadata": {"source": "extension:test-ext"},
},
sort_keys=False,
)
(fallback_skill / "SKILL.md").write_text(
f"---\n{frontmatter}---\n\nFallback\n",
encoding="utf-8",
)
manager = ExtensionManager(project_dir)
found = manager._find_extension_skill_dirs(
[fallback_skill.name],
"test-ext",
create_skills_dir=False,
)
assert found == [fallback_skill.resolve()]
assert not configured_dir.exists()
def test_read_only_discovery_includes_fallback_when_target_is_symlinked(
self, project_dir
):
"""Backup and remove agree when the configured target is unsafe."""
_create_init_options(project_dir, ai="claude", ai_skills=True)
configured_dir = project_dir / ".claude" / "skills"
configured_dir.parent.mkdir(parents=True)
symlink_target = project_dir / "linked-skills"
symlink_target.mkdir()
try:
os.symlink(
symlink_target,
configured_dir,
target_is_directory=True,
)
except OSError:
pytest.skip("Current platform/user cannot create directory symlinks")
fallback_skill = (
project_dir
/ ".agents"
/ "skills"
/ "speckit-test-ext-hello"
)
fallback_skill.mkdir(parents=True)
frontmatter = yaml.safe_dump(
{
"name": fallback_skill.name,
"description": "Fallback skill",
"metadata": {"source": "extension:test-ext"},
},
sort_keys=False,
)
(fallback_skill / "SKILL.md").write_text(
f"---\n{frontmatter}---\n\nFallback\n",
encoding="utf-8",
)
manager = ExtensionManager(project_dir)
found = manager._find_extension_skill_dirs(
[fallback_skill.name],
"test-ext",
create_skills_dir=False,
)
assert found == [fallback_skill.resolve()]
assert configured_dir.is_symlink()
def test_fallback_scan_rejects_symlinked_root_outside_project(
self, project_dir, temp_dir
):
"""Fallback discovery must not authorize a root by resolving it first."""
if not hasattr(os, "symlink"):
pytest.skip("symlinks are unavailable")
from specify_cli import AGENT_CONFIG, DEFAULT_SKILLS_DIR
skill_name = "speckit-test-ext-hello"
frontmatter = yaml.safe_dump(
{
"name": skill_name,
"description": "Extension skill",
"metadata": {"source": "extension:test-ext"},
},
sort_keys=False,
)
skill_content = f"---\n{frontmatter}---\n\nExtension skill\n"
safe_skill = project_dir / DEFAULT_SKILLS_DIR / skill_name
safe_skill.mkdir(parents=True)
(safe_skill / "SKILL.md").write_text(skill_content, encoding="utf-8")
outside_skills = temp_dir / "outside-skills"
outside_skill = outside_skills / skill_name
outside_skill.mkdir(parents=True)
outside_skill_file = outside_skill / "SKILL.md"
outside_skill_file.write_text(skill_content, encoding="utf-8")
agent_folder = AGENT_CONFIG["claude"]["folder"].rstrip("/")
symlinked_fallback = project_dir / agent_folder / "skills"
symlinked_fallback.parent.mkdir(parents=True)
try:
os.symlink(
outside_skills,
symlinked_fallback,
target_is_directory=True,
)
except OSError:
pytest.skip("Current platform/user cannot create directory symlinks")
manager = ExtensionManager(project_dir)
found = manager._find_extension_skill_dirs(
[skill_name],
"test-ext",
)
assert found == [safe_skill.resolve()]
manager._unregister_extension_skills([skill_name], "test-ext")
assert not safe_skill.exists()
assert outside_skill.is_dir()
assert outside_skill_file.read_text(encoding="utf-8") == skill_content
def test_configured_global_skills_root_remains_supported(
self, project_dir, temp_dir, monkeypatch
):
"""A trusted configured global root must not be treated as a fallback."""
home = temp_dir / "home"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: home)
_create_init_options(project_dir, ai="hermes", ai_skills=True)
skill_name = "speckit-test-ext-hello"
skill_dir = home / ".hermes" / "skills" / skill_name
skill_dir.mkdir(parents=True)
frontmatter = yaml.safe_dump(
{
"name": skill_name,
"description": "Global extension skill",
"metadata": {"source": "extension:test-ext"},
},
sort_keys=False,
)
(skill_dir / "SKILL.md").write_text(
f"---\n{frontmatter}---\n\nGlobal extension skill\n",
encoding="utf-8",
)
manager = ExtensionManager(project_dir)
found = manager._find_extension_skill_dirs(
[skill_name],
"test-ext",
skills_dir=skill_dir.parent,
create_skills_dir=False,
)
assert found == [skill_dir.resolve()]
manager._unregister_extension_skills(
[skill_name], "test-ext", skills_dir=skill_dir.parent
)
assert not skill_dir.exists()
def test_skills_removed_on_extension_remove(self, skills_project, extension_dir):
"""Removing an extension should clean up its skill directories."""
project_dir, skills_dir = skills_project

File diff suppressed because it is too large Load Diff

View File

@@ -2145,6 +2145,29 @@ class TestExtensionManager:
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
assert ext_dir.exists()
def test_install_from_zip_rejects_symlink_entry(
self, extension_dir, project_dir, temp_dir
):
"""Extension ZIPs delegate to the shared symlink-safe extractor."""
import stat
import zipfile
zip_path = temp_dir / "symlink-extension.zip"
link = zipfile.ZipInfo("templates/escape")
link.create_system = 3
link.external_attr = (stat.S_IFLNK | 0o777) << 16
with zipfile.ZipFile(zip_path, "w") as zf:
for file_path in extension_dir.rglob("*"):
if file_path.is_file():
zf.write(file_path, file_path.relative_to(extension_dir))
zf.writestr(link, "../../outside")
manager = ExtensionManager(project_dir)
with pytest.raises(ValidationError, match="Unsafe symlink"):
manager.install_from_zip(zip_path, "0.1.0")
assert not manager.registry.is_installed("test-ext")
def test_install_duplicate_error_mentions_force(self, extension_dir, project_dir):
"""Test that duplicate install error message suggests --force."""
manager = ExtensionManager(project_dir)
@@ -4685,7 +4708,7 @@ class TestExtensionCatalog:
catalog_data = {"schema_version": "1.0", "extensions": {}}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(catalog_data).encode()
mock_response.read.side_effect = io.BytesIO(json.dumps(catalog_data).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/catalog.json"
@@ -4829,7 +4852,7 @@ class TestExtensionCatalog:
catalog = self._make_catalog(temp_dir)
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode()
mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
@@ -4898,7 +4921,7 @@ class TestExtensionCatalog:
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
@@ -4946,7 +4969,7 @@ class TestExtensionCatalog:
catalog = self._make_catalog(temp_dir)
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode()
mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
@@ -4987,7 +5010,7 @@ class TestExtensionCatalog:
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
@@ -5026,7 +5049,7 @@ class TestExtensionCatalog:
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
@@ -5101,7 +5124,7 @@ class TestExtensionCatalog:
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode("utf-8")
mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode("utf-8")).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
@@ -5152,11 +5175,13 @@ class TestExtensionCatalog:
"schema_version": "1.0",
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
def make_response():
mock_response = MagicMock()
mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
return mock_response
# Simulate an unwritable cache dir: every write_text under the
# cache directory raises PermissionError (an OSError subclass).
@@ -5169,7 +5194,7 @@ class TestExtensionCatalog:
monkeypatch.setattr(_PathCls, "write_text", failing_write_text)
with patch.object(catalog, "_open_url", return_value=mock_response):
with patch.object(catalog, "_open_url", side_effect=lambda *a, **kw: make_response()):
# Legacy single-catalog path.
assert catalog.fetch_catalog(force_refresh=True) == valid
@@ -5205,7 +5230,7 @@ class TestExtensionCatalog:
},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode()
mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
@@ -5303,13 +5328,98 @@ class TestExtensionCatalog:
from unittest.mock import MagicMock
resp = MagicMock()
resp.read.return_value = data
resp.read.side_effect = io.BytesIO(data).read
# 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_fetch_single_catalog_rejects_oversized_body_without_cache(
self, temp_dir, monkeypatch
):
"""Catalog bounds are enforced at the extension call site."""
from unittest.mock import patch
catalog = self._make_catalog(temp_dir)
entry = CatalogEntry(
url="https://example.com/catalog.json",
name="default",
priority=1,
install_allowed=True,
)
body = b'{"schema_version":"1.0","extensions":{}}'
response = self._mock_response(body)
response.geturl.return_value = entry.url
monkeypatch.setattr(_ext_module, "MAX_JSON_CATALOG_BYTES", len(body) - 1)
with patch.object(catalog, "_open_url", return_value=response):
with pytest.raises(ExtensionError, match="exceeds maximum size"):
catalog._fetch_single_catalog(entry, force_refresh=True)
assert not catalog.cache_dir.exists() or not any(catalog.cache_dir.iterdir())
def test_download_extension_rejects_oversized_body_without_output(
self, temp_dir, monkeypatch
):
"""Package bounds fail before checksum verification or disk writes."""
from unittest.mock import patch
from specify_cli._download_security import (
read_response_limited as real_read_response_limited,
)
catalog = self._make_catalog(temp_dir)
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": "https://example.com/test-ext.zip",
}
def read_with_tiny_limit(response, **kwargs):
kwargs.pop("max_bytes", None)
return real_read_response_limited(response, max_bytes=4, **kwargs)
monkeypatch.setattr(
_ext_module,
"read_response_limited",
read_with_tiny_limit,
)
with patch.object(_ext_module, "verify_archive_sha256") as verify, \
patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch.object(
catalog,
"_open_url",
return_value=self._mock_response(b"12345"),
):
with pytest.raises(ExtensionError, match="exceeds maximum size"):
catalog.download_extension("test-ext", target_dir=temp_dir)
verify.assert_not_called()
assert not (temp_dir / "test-ext-1.0.0.zip").exists()
def test_download_extension_rejects_unsafe_output_filename(self, temp_dir):
"""Catalog-controlled IDs cannot escape the requested target directory."""
from unittest.mock import patch
catalog = self._make_catalog(temp_dir)
outside_stem = temp_dir.parent / "outside-extension"
extension_id = str(outside_stem)
ext_info = {
"id": extension_id,
"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") as open_url:
with pytest.raises(ExtensionError, match="filename"):
catalog.download_extension(extension_id, target_dir=temp_dir)
open_url.assert_not_called()
assert not Path(f"{outside_stem}-1.0.0.zip").exists()
def test_download_extension_accepts_matching_sha256(self, temp_dir):
"""A catalog ``sha256`` that matches the archive is accepted."""
import hashlib
@@ -5361,16 +5471,24 @@ class TestExtensionCatalog:
from unittest.mock import patch
catalog = self._make_catalog(temp_dir)
for bad_url in ("https://[::1", "https://[not-an-ip]/x"):
for bad_url in (
"https://[::1",
"https://[not-an-ip]/x",
"https://example.com:65536/x",
"https:///x",
123,
):
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": bad_url,
}
with patch.object(catalog, "get_extension_info", return_value=ext_info):
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch.object(catalog, "_open_url") as open_url:
with pytest.raises(ExtensionError, match="malformed"):
catalog.download_extension("test-ext", target_dir=temp_dir)
open_url.assert_not_called()
def test_download_extension_without_sha256_still_succeeds(self, temp_dir):
"""Entries without ``sha256`` keep working (backwards compatible)."""
@@ -5407,7 +5525,7 @@ class TestExtensionCatalog:
zip_bytes = zip_buf.getvalue()
asset_response = MagicMock()
asset_response.read.return_value = zip_bytes
asset_response.read.side_effect = io.BytesIO(zip_bytes).read
asset_response.__enter__ = lambda s: s
asset_response.__exit__ = MagicMock(return_value=False)
@@ -6962,6 +7080,38 @@ class TestExtensionAddCLI:
plain = strip_ansi(result.output)
assert "Invalid URL" in plain
@pytest.mark.parametrize(
"url",
[
"https:///ext.zip",
"https://example.com:99999/ext.zip",
],
)
def test_add_from_invalid_url_exits_before_prompt(self, tmp_path, url):
"""Hostless URLs and invalid ports fail before prompting or downloading."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm") as confirm, \
patch("specify_cli.authentication.http.open_url") as open_url:
result = runner.invoke(
app,
["extension", "add", "my-ext", "--from", url],
catch_exceptions=True,
)
assert result.exit_code == 1
assert "Invalid URL" in strip_ansi(result.output)
confirm.assert_not_called()
open_url.assert_not_called()
def test_add_from_bracketed_non_ip_url_exits_cleanly(self, tmp_path):
"""A bracketed-but-invalid IPv6 host must produce a clean error, not a
ValueError traceback. "https://[not-an-ip]/ext.zip" is a malformed
@@ -7207,6 +7357,62 @@ class TestExtensionAddCLI:
assert "did not return a ZIP archive" in result.output
install.assert_not_called()
def test_add_from_url_rejects_oversized_download_before_install(
self, tmp_path, monkeypatch
):
"""The direct URL path must use the same bounded reader as catalogs."""
import io
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
from specify_cli.extensions import _commands as extension_commands
class FakeResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def reject_oversized(*_args, **_kwargs):
raise ExtensionError("extension URL download exceeds maximum size")
monkeypatch.setattr(
extension_commands,
"read_response_limited",
reject_oversized,
raising=False,
)
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm", return_value=True), \
patch(
"specify_cli.authentication.http.open_url",
return_value=FakeResponse(_MINIMAL_ZIP_BYTES),
), \
patch.object(ExtensionManager, "install_from_zip") as install:
result = runner.invoke(
app,
[
"extension",
"add",
"my-ext",
"--from",
"https://example.com/ext.zip",
],
catch_exceptions=True,
)
assert result.exit_code == 1
assert "exceeds maximum size" in result.output
install.assert_not_called()
def test_add_from_url_resolves_ghes_release_asset(self, tmp_path):
"""A GHES release-download URL resolves to /api/v3 with octet-stream Accept."""
import io
@@ -7401,7 +7607,7 @@ class TestDownloadExtensionBundled:
}
mock_response = MagicMock()
mock_response.read.return_value = b"fake zip data"
mock_response.read.side_effect = io.BytesIO(b"fake zip data").read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
@@ -7479,7 +7685,12 @@ class TestExtensionUpdateCLI:
return ext_dir
@staticmethod
def _create_catalog_zip(zip_path: Path, version: str):
def _create_catalog_zip(
zip_path: Path,
version: str,
manifest_path: str = "extension.yml",
extra_manifest_path: str | None = None,
):
"""Create a minimal ZIP that passes extension_update ID validation."""
import zipfile
import yaml
@@ -7497,9 +7708,243 @@ class TestExtensionUpdateCLI:
}
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("extension.yml", yaml.dump(manifest, sort_keys=False))
manifest_text = yaml.dump(manifest, sort_keys=False)
zf.writestr(manifest_path, manifest_text)
if extra_manifest_path is not None:
zf.writestr(extra_manifest_path, manifest_text)
def test_update_success_preserves_installed_at(self, tmp_path):
@pytest.mark.parametrize(
"manifest_path",
[
"../extension.yml",
"/extension.yml",
"./extension.yml",
"C:/extension.yml",
],
)
def test_update_rejects_unsafe_manifest_path_before_removal(
self, tmp_path, manifest_path
):
"""Unsafe manifest paths fail before the installed extension is removed."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
(project_dir / ".claude" / "skills").mkdir(parents=True)
manager = ExtensionManager(project_dir)
v1_dir = self._create_extension_source(tmp_path, "1.0.0")
manager.install_from_directory(v1_dir, "0.1.0")
installed_extension_dir = manager.extensions_dir / "test-ext"
removed_paths = []
real_rmtree = shutil.rmtree
def track_rmtree(path, *args, **kwargs):
removed_paths.append(Path(path).resolve())
return real_rmtree(path, *args, **kwargs)
zip_path = tmp_path / "unsafe-manifest.zip"
self._create_catalog_zip(
zip_path,
"2.0.0",
manifest_path=manifest_path,
)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch.object(ExtensionCatalog, "get_extension_info", return_value={
"id": "test-ext",
"name": "Test Extension",
"version": "2.0.0",
"_install_allowed": True,
}), \
patch.object(
ExtensionCatalog,
"download_extension",
return_value=zip_path,
), \
patch.object(shutil, "rmtree", side_effect=track_rmtree), \
patch.object(ExtensionManager, "remove") as remove, \
patch.object(ExtensionManager, "install_from_zip") as install:
result = runner.invoke(
app,
["extension", "update", "test-ext"],
input="y\n",
catch_exceptions=True,
)
assert result.exit_code == 1
assert "Unsafe path in ZIP archive" in result.output
remove.assert_not_called()
install.assert_not_called()
assert installed_extension_dir.resolve() not in removed_paths
assert not list(
(manager.extensions_dir / ".backup").glob(
"update-*-*"
)
)
assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0"
@pytest.mark.parametrize(
("first_path", "second_path"),
[
("repo/extension.yml", "repo\\extension.yml"),
("repo/extension.yml", "repo/EXTENSION.YML"),
("caf\u00e9/extension.yml", "cafe\u0301/extension.yml"),
],
)
def test_update_rejects_normalized_manifest_collision_before_removal(
self, tmp_path, first_path, second_path
):
"""Pre-scan and extraction must agree on the manifest identity."""
import yaml
import zipfile
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
(project_dir / ".claude" / "skills").mkdir(parents=True)
manager = ExtensionManager(project_dir)
v1_dir = self._create_extension_source(tmp_path, "1.0.0")
manager.install_from_directory(v1_dir, "0.1.0")
valid_manifest = yaml.safe_dump(
{
"schema_version": "1.0",
"extension": {
"id": "test-ext",
"name": "Test Extension",
"version": "2.0.0",
},
}
)
injected_manifest = yaml.safe_dump(
{
"schema_version": "1.0",
"extension": {
"id": "injected",
"name": "Injected",
"version": "2.0.0",
},
}
)
zip_path = tmp_path / "manifest-collision.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(first_path, valid_manifest)
zf.writestr(second_path, injected_manifest)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch.object(ExtensionCatalog, "get_extension_info", return_value={
"id": "test-ext",
"name": "Test Extension",
"version": "2.0.0",
"_install_allowed": True,
}), \
patch.object(
ExtensionCatalog,
"download_extension",
return_value=zip_path,
), \
patch.object(ExtensionManager, "remove") as remove, \
patch.object(ExtensionManager, "install_from_zip") as install:
result = runner.invoke(
app,
["extension", "update", "test-ext"],
input="y\n",
catch_exceptions=True,
)
assert result.exit_code == 1
assert "multiple extension.yml" in result.output
remove.assert_not_called()
install.assert_not_called()
assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0"
def test_update_preflights_entry_count_before_opening_zip(
self, tmp_path
):
"""Manifest inspection must not bypass the bounded ZIP opener."""
import struct
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
(project_dir / ".claude" / "skills").mkdir(parents=True)
manager = ExtensionManager(project_dir)
v1_dir = self._create_extension_source(tmp_path, "1.0.0")
manager.install_from_directory(v1_dir, "0.1.0")
zip_path = tmp_path / "too-many.zip"
zip_path.write_bytes(
struct.pack(
"<4s4H2LH",
b"PK\x05\x06",
0,
0,
513,
513,
0,
0,
0,
)
)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch.object(ExtensionCatalog, "get_extension_info", return_value={
"id": "test-ext",
"name": "Test Extension",
"version": "2.0.0",
"_install_allowed": True,
}), \
patch.object(
ExtensionCatalog,
"download_extension",
return_value=zip_path,
), \
patch(
"specify_cli._download_security.zipfile.ZipFile",
side_effect=AssertionError("ZipFile constructor was called"),
), \
patch.object(ExtensionManager, "remove") as remove, \
patch.object(ExtensionManager, "install_from_zip") as install:
result = runner.invoke(
app,
["extension", "update", "test-ext"],
input="y\n",
catch_exceptions=True,
)
assert result.exit_code == 1
assert "too many entries" in result.output
remove.assert_not_called()
install.assert_not_called()
@pytest.mark.parametrize(
("manifest_path", "extra_manifest_path"),
[
("extension.yml", None),
("repo/extension.yml", None),
("extension.yml", "repo/extension.yml"),
],
)
def test_update_success_preserves_installed_at(
self, tmp_path, manifest_path, extra_manifest_path
):
"""Successful update should keep original installed_at and apply new version."""
from typer.testing import CliRunner
from unittest.mock import patch
@@ -7520,7 +7965,12 @@ class TestExtensionUpdateCLI:
).read_text()
zip_path = tmp_path / "test-ext-update.zip"
self._create_catalog_zip(zip_path, "2.0.0")
self._create_catalog_zip(
zip_path,
"2.0.0",
manifest_path=manifest_path,
extra_manifest_path=extra_manifest_path,
)
v2_dir = self._create_extension_source(tmp_path, "2.0.0")
def fake_install_from_zip(self_obj, _zip_path, speckit_version):
@@ -7619,6 +8069,167 @@ class TestExtensionUpdateCLI:
for cmd_file in command_files:
assert cmd_file.exists(), f"Expected command file to be restored after rollback: {cmd_file}"
def test_update_failure_after_skill_registration_restores_old_skills(
self, tmp_path, monkeypatch
):
"""Rollback must not depend on a new registry entry to restore skills."""
import zipfile
import yaml
from specify_cli import app
from typer.testing import CliRunner
from unittest.mock import patch
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", lambda: fake_home)
project_dir = tmp_path / "project"
project_dir.mkdir()
specify_dir = project_dir / ".specify"
specify_dir.mkdir()
copilot_agents_dir = project_dir / ".github" / "agents"
copilot_agents_dir.mkdir(parents=True)
(specify_dir / "init-options.json").write_text(
json.dumps(
{
"ai": "claude",
"ai_skills": True,
"script": "sh",
}
),
encoding="utf-8",
)
manager = ExtensionManager(project_dir)
v1_dir = self._create_extension_source(tmp_path, "1.0.0")
manager.install_from_directory(
v1_dir,
"0.1.0",
register_commands=False,
)
old_registry_entry = manager.registry.get("test-ext")
skills_dir = project_dir / ".claude" / "skills"
old_skill = skills_dir / "speckit-test-ext-hello"
old_skill_content = (old_skill / "SKILL.md").read_text(encoding="utf-8")
assert old_registry_entry["registered_skills"] == [old_skill.name]
new_skill = skills_dir / "speckit-test-ext-new"
new_skill.mkdir()
user_skill_content = (
"---\n"
"name: user-new-skill\n"
"description: User-owned skill\n"
"metadata:\n"
" source: user\n"
"---\n\nUSER SKILL\n"
)
(new_skill / "SKILL.md").write_text(
user_skill_content,
encoding="utf-8",
)
user_support_file = new_skill / "support.txt"
user_support_file.write_text("USER CONTENT", encoding="utf-8")
v2_dir = self._create_extension_source(tmp_path, "2.0.0")
manifest_path = v2_dir / "extension.yml"
manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
manifest["provides"]["commands"].append(
{
"name": "speckit.test-ext.new",
"file": "commands/new.md",
"description": "New command",
}
)
manifest["provides"]["commands"].append(
{
"name": "speckit.test-ext.fresh",
"file": "commands/fresh.md",
"description": "Fresh command",
}
)
manifest_path.write_text(
yaml.safe_dump(manifest, sort_keys=False),
encoding="utf-8",
)
(v2_dir / "commands" / "hello.md").write_text(
"---\ndescription: New hello\n---\n\nNEW HELLO\n",
encoding="utf-8",
)
(v2_dir / "commands" / "new.md").write_text(
"---\ndescription: New command\n---\n\nNEW COMMAND\n",
encoding="utf-8",
)
(v2_dir / "commands" / "fresh.md").write_text(
"---\ndescription: Fresh command\n---\n\nFRESH COMMAND\n",
encoding="utf-8",
)
zip_path = tmp_path / "test-ext-update.zip"
with zipfile.ZipFile(zip_path, "w") as archive:
for source_path in v2_dir.rglob("*"):
if source_path.is_file():
archive.write(
source_path,
source_path.relative_to(v2_dir),
)
def fail_after_skill_registration(self, manifest):
raise RuntimeError("Hook registration failed")
runner = CliRunner()
with (
patch.object(Path, "cwd", return_value=project_dir),
patch.object(
ExtensionCatalog,
"get_extension_info",
return_value={
"id": "test-ext",
"name": "Test Extension",
"version": "2.0.0",
"_install_allowed": True,
},
),
patch.object(
ExtensionCatalog,
"download_extension",
return_value=zip_path,
),
patch.object(
HookExecutor,
"register_hooks",
fail_after_skill_registration,
),
):
result = runner.invoke(
app,
["extension", "update", "test-ext"],
input="y\n",
catch_exceptions=True,
)
assert result.exit_code == 1, result.output
assert "Hook registration failed" in result.output
assert "Rollback successful" in result.output
assert ExtensionManager(project_dir).registry.get("test-ext") == old_registry_entry
assert (old_skill / "SKILL.md").read_text(encoding="utf-8") == old_skill_content
assert user_support_file.read_text(encoding="utf-8") == "USER CONTENT"
assert (
new_skill / "SKILL.md"
).read_text(encoding="utf-8") == user_skill_content
assert not (skills_dir / "speckit-test-ext-fresh").exists()
for command_name in ("hello", "new", "fresh"):
qualified_name = f"speckit.test-ext.{command_name}"
assert not (
copilot_agents_dir / f"{qualified_name}.agent.md"
).exists()
assert not (
project_dir
/ ".github"
/ "prompts"
/ f"{qualified_name}.prompt.md"
).exists()
@pytest.mark.parametrize(
("manifest_text", "expected_detail"),
[

View File

@@ -654,6 +654,28 @@ class TestPresetManager:
with pytest.raises(PresetValidationError, match="No preset.yml found"):
manager.install_from_zip(zip_path, "0.1.5")
def test_install_from_zip_rejects_symlink_entry(
self, project_dir, pack_dir, temp_dir
):
"""Preset ZIPs delegate to the shared symlink-safe extractor."""
import stat
zip_path = temp_dir / "symlink-preset.zip"
link = zipfile.ZipInfo("templates/escape")
link.create_system = 3
link.external_attr = (stat.S_IFLNK | 0o777) << 16
with zipfile.ZipFile(zip_path, "w") as zf:
for file_path in pack_dir.rglob("*"):
if file_path.is_file():
zf.write(file_path, file_path.relative_to(pack_dir))
zf.writestr(link, "../../outside")
manager = PresetManager(project_dir)
with pytest.raises(PresetValidationError, match="Unsafe symlink"):
manager.install_from_zip(zip_path, "0.1.5")
assert not manager.registry.is_installed("test-pack")
def test_remove(self, project_dir, pack_dir):
"""Test removing a preset."""
manager = PresetManager(project_dir)
@@ -1740,7 +1762,7 @@ class TestPresetCatalog:
catalog_data = {"schema_version": "1.0", "presets": {}}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(catalog_data).encode()
mock_response.read.side_effect = io.BytesIO(json.dumps(catalog_data).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/presets/catalog.json"
@@ -1893,7 +1915,7 @@ class TestPresetCatalog:
catalog = PresetCatalog(project_dir)
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode()
mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
# A real urllib response reports the final URL (== request URL with no
@@ -1965,7 +1987,7 @@ class TestPresetCatalog:
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = catalog.DEFAULT_CATALOG_URL
@@ -2013,7 +2035,7 @@ class TestPresetCatalog:
catalog = PresetCatalog(project_dir)
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode()
mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
@@ -2055,7 +2077,7 @@ class TestPresetCatalog:
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
@@ -2094,7 +2116,7 @@ class TestPresetCatalog:
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
@@ -2165,7 +2187,7 @@ class TestPresetCatalog:
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode("utf-8")
mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode("utf-8")).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
@@ -2214,11 +2236,13 @@ class TestPresetCatalog:
"schema_version": "1.0",
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = catalog.DEFAULT_CATALOG_URL
def make_response():
mock_response = MagicMock()
mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = catalog.DEFAULT_CATALOG_URL
return mock_response
# Simulate an unwritable cache dir: every write_text under the
# cache directory raises PermissionError (an OSError subclass).
@@ -2231,7 +2255,7 @@ class TestPresetCatalog:
monkeypatch.setattr(_PathCls, "write_text", failing_write_text)
with patch.object(catalog, "_open_url", return_value=mock_response):
with patch.object(catalog, "_open_url", side_effect=lambda *a, **kw: make_response()):
# Legacy single-catalog path.
assert catalog.fetch_catalog(force_refresh=True) == valid
@@ -2268,7 +2292,7 @@ class TestPresetCatalog:
},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode()
mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
@@ -2361,13 +2385,109 @@ class TestPresetCatalog:
zip_bytes = zip_buf.getvalue()
resp = MagicMock()
resp.read.return_value = zip_bytes
resp.read.side_effect = io.BytesIO(zip_bytes).read
# 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_fetch_single_catalog_rejects_oversized_body_without_cache(
self, project_dir, monkeypatch
):
"""Catalog bounds are enforced at the preset call site."""
import specify_cli.presets as preset_module
from unittest.mock import patch
catalog = PresetCatalog(project_dir)
entry = PresetCatalogEntry(
url="https://example.com/catalog.json",
name="default",
priority=1,
install_allowed=True,
)
body = b'{"schema_version":"1.0","presets":{}}'
response = MagicMock()
response.read.side_effect = io.BytesIO(body).read
response.__enter__.return_value = response
response.__exit__.return_value = False
response.geturl.return_value = entry.url
monkeypatch.setattr(
preset_module,
"MAX_JSON_CATALOG_BYTES",
len(body) - 1,
)
with patch.object(catalog, "_open_url", return_value=response):
with pytest.raises(PresetError, match="exceeds maximum size"):
catalog._fetch_single_catalog(entry, force_refresh=True)
assert not catalog.cache_dir.exists() or not any(catalog.cache_dir.iterdir())
def test_download_pack_rejects_oversized_body_without_output(
self, project_dir, monkeypatch
):
"""Package bounds fail before checksum verification or disk writes."""
import specify_cli.presets as preset_module
from unittest.mock import patch
from specify_cli._download_security import (
read_response_limited as real_read_response_limited,
)
catalog = PresetCatalog(project_dir)
pack_info = {
"id": "test-pack",
"name": "Test Pack",
"version": "1.0.0",
"download_url": "https://example.com/test-pack.zip",
"_install_allowed": True,
}
response = MagicMock()
response.read.side_effect = io.BytesIO(b"12345").read
response.__enter__.return_value = response
response.__exit__.return_value = False
def read_with_tiny_limit(stream, **kwargs):
kwargs.pop("max_bytes", None)
return real_read_response_limited(stream, max_bytes=4, **kwargs)
monkeypatch.setattr(
preset_module,
"read_response_limited",
read_with_tiny_limit,
)
with patch.object(preset_module, "verify_archive_sha256") as verify, \
patch.object(catalog, "get_pack_info", return_value=pack_info), \
patch.object(catalog, "_open_url", return_value=response):
with pytest.raises(PresetError, match="exceeds maximum size"):
catalog.download_pack("test-pack", target_dir=project_dir)
verify.assert_not_called()
assert not (project_dir / "test-pack-1.0.0.zip").exists()
def test_download_pack_rejects_unsafe_output_filename(self, project_dir):
"""Catalog-controlled IDs cannot escape the requested target directory."""
from unittest.mock import patch
catalog = PresetCatalog(project_dir)
outside_stem = project_dir.parent / "outside-preset"
pack_id = str(outside_stem)
pack_info = {
"id": pack_id,
"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") as open_url:
with pytest.raises(PresetError, match="filename"):
catalog.download_pack(pack_id, target_dir=project_dir)
open_url.assert_not_called()
assert not Path(f"{outside_stem}-1.0.0.zip").exists()
def test_download_pack_accepts_matching_sha256(self, project_dir):
"""A catalog ``sha256`` that matches the preset archive is accepted."""
import hashlib
@@ -2420,7 +2540,13 @@ class TestPresetCatalog:
from unittest.mock import patch
catalog = PresetCatalog(project_dir)
for bad_url in ("https://[::1", "https://[not-an-ip]/x"):
for bad_url in (
"https://[::1",
"https://[not-an-ip]/x",
"https://example.com:65536/x",
"https:///x",
123,
):
pack_info = {
"id": "test-pack",
"name": "Test Pack",
@@ -2428,9 +2554,11 @@ class TestPresetCatalog:
"download_url": bad_url,
"_install_allowed": True,
}
with patch.object(catalog, "get_pack_info", return_value=pack_info):
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
patch.object(catalog, "_open_url") as open_url:
with pytest.raises(PresetError, match="malformed"):
catalog.download_pack("test-pack", target_dir=project_dir)
open_url.assert_not_called()
def test_download_pack_without_sha256_skips_verification(self, project_dir):
"""A catalog entry with no ``sha256`` keeps working: verification is
@@ -2471,7 +2599,7 @@ class TestPresetCatalog:
zip_bytes = zip_buf.getvalue()
asset_response = MagicMock()
asset_response.read.return_value = zip_bytes
asset_response.read.side_effect = io.BytesIO(zip_bytes).read
asset_response.__enter__ = lambda s: s
asset_response.__exit__ = MagicMock(return_value=False)
@@ -9791,8 +9919,8 @@ class TestBundledPresetLocator:
assert "redirected to a disallowed URL" in output
assert "must use HTTPS with a hostname" in output
def test_preset_add_from_url_streams_download_to_zip(self, project_dir, monkeypatch):
"""URL installs stream response bytes to disk before installing the ZIP."""
def test_preset_add_from_url_reads_in_bounded_chunks(self, project_dir, monkeypatch):
"""URL installs read the response in bounded chunks."""
from specify_cli.presets._commands import preset_add
class FakeResponse(io.BytesIO):
@@ -9840,6 +9968,65 @@ class TestBundledPresetLocator:
"priority": 7,
}
def test_preset_add_from_url_rejects_oversized_download(
self, project_dir, monkeypatch, capsys
):
"""An oversized direct download fails before preset installation."""
import typer
from specify_cli._download_security import (
read_response_limited as real_read_response_limited,
)
from specify_cli.presets import _commands as preset_commands
class FakeResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def geturl(self):
return "https://example.com/preset.zip"
def read_with_tiny_limit(response, **kwargs):
kwargs.pop("max_bytes", None)
return real_read_response_limited(response, max_bytes=4, **kwargs)
installed = False
def fake_install_from_zip(*_args, **_kwargs):
nonlocal installed
installed = True
monkeypatch.setattr(
preset_commands,
"read_response_limited",
read_with_tiny_limit,
)
monkeypatch.setattr(
"specify_cli._require_specify_project",
lambda: project_dir,
)
monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0")
monkeypatch.setattr(
"specify_cli.authentication.http.open_url",
lambda *_args, **_kwargs: FakeResponse(b"12345"),
)
monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip)
with pytest.raises(typer.Exit) as exc_info:
preset_commands.preset_add(
preset_id=None,
from_url="https://example.com/preset.zip",
dev=None,
priority=10,
)
assert exc_info.value.exit_code == 1
output = " ".join(strip_ansi(capsys.readouterr().out).split())
assert "exceeds maximum size of 4 bytes" in output
assert installed is False
def test_bundled_preset_in_catalog(self):
"""Verify the lean preset is listed in catalog.json with bundled marker."""
catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json"

View File

@@ -13,7 +13,13 @@ TRAVERSAL_PAYLOADS = [
"../pwned",
"../../etc/passwd",
"subdir/../../escape",
"link/../victim",
"/absolute/evil",
"NUL",
"name:stream",
"x?y",
"trailing.",
"group\\run",
]
@@ -89,7 +95,8 @@ class TestAliasTraversal:
@pytest.mark.parametrize("bad_alias", TRAVERSAL_PAYLOADS)
def test_gemini_rejects_traversal_in_alias(self, tmp_path, bad_alias):
project, ext_dir = _project_and_source(tmp_path)
(project / ".gemini" / "commands").mkdir(parents=True)
commands_dir = project / ".gemini" / "commands"
commands_dir.mkdir(parents=True)
registrar = CommandRegistrar()
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
@@ -102,12 +109,15 @@ class TestAliasTraversal:
)
_assert_no_stray_files(tmp_path, Path(bad_alias).name.replace("/", ""))
assert list(commands_dir.rglob("*")) == []
@pytest.mark.parametrize("bad_alias", TRAVERSAL_PAYLOADS)
def test_copilot_rejects_traversal_in_alias(self, tmp_path, bad_alias):
project, ext_dir = _project_and_source(tmp_path)
(project / ".github" / "agents").mkdir(parents=True)
(project / ".github" / "prompts").mkdir(parents=True)
agents_dir = project / ".github" / "agents"
prompts_dir = project / ".github" / "prompts"
agents_dir.mkdir(parents=True)
prompts_dir.mkdir(parents=True)
registrar = CommandRegistrar()
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
@@ -120,6 +130,8 @@ class TestAliasTraversal:
)
_assert_no_stray_files(tmp_path, Path(bad_alias).name.replace("/", ""))
assert list(agents_dir.rglob("*")) == []
assert list(prompts_dir.rglob("*")) == []
class TestCopilotPromptTraversal:
@@ -290,6 +302,13 @@ class TestRelativeExtensionPathPolicy:
"\\\\server\\share\\x.md",
"../escape.md",
"commands/../../escape.md",
"NUL",
"commands/CON.md",
"commands\\run.md",
"name:stream",
"x?y",
"trailing.",
"directory/",
],
)
def test_unsafe_values_report_violation(self, value):
@@ -380,3 +399,27 @@ class TestReadSkipWarning:
/ "speckit-myext-hi"
/ "SKILL.md"
).exists()
def test_copilot_nested_alias_creates_companion_prompt(self, tmp_path):
project, ext_dir = _project_and_source(tmp_path)
agents_dir = project / ".github" / "agents"
agents_dir.mkdir(parents=True)
registrar = CommandRegistrar()
registered = registrar.register_commands(
"copilot",
[_cmd("speckit.myext.hello", ["group/run"])],
"myext",
ext_dir,
project,
)
assert registered == ["speckit.myext.hello", "group/run"]
assert (agents_dir / "group" / "run.agent.md").is_file()
assert (
project
/ ".github"
/ "prompts"
/ "group"
/ "run.prompt.md"
).is_file()

View File

@@ -6398,6 +6398,7 @@ class TestWorkflowCatalog:
[
"https://[::1", # unterminated IPv6 bracket
"https://[not-an-ip]/x", # bracketed non-IP host
"https://example.com:notaport/catalog.json",
],
)
def test_validate_url_malformed_raises_validation_error(self, project_dir, url):
@@ -6507,6 +6508,62 @@ class TestWorkflowCatalog:
catalog._fetch_single_catalog(entry, force_refresh=True)
assert captured["rv"] is not None
def test_fetch_rejects_oversized_catalog_response(
self, project_dir, monkeypatch
):
from specify_cli.authentication import http as auth_http
from specify_cli.workflows import catalog as catalog_module
from specify_cli.workflows.catalog import (
WorkflowCatalog,
WorkflowCatalogEntry,
WorkflowCatalogError,
)
monkeypatch.setattr(catalog_module, "MAX_JSON_CATALOG_BYTES", 32)
requested_sizes: list[int] = []
class _FakeResponse:
def __init__(self):
self.body = b"x" * 64
self.offset = 0
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def geturl(self):
return "https://example.com/catalog.json"
def read(self, size=-1):
requested_sizes.append(size)
assert size >= 0
chunk_size = min(size, 7)
chunk = self.body[self.offset : self.offset + chunk_size]
self.offset += len(chunk)
return chunk
monkeypatch.setattr(
auth_http,
"open_url",
lambda url, timeout=30, redirect_validator=None: _FakeResponse(),
)
catalog = WorkflowCatalog(project_dir)
entry = WorkflowCatalogEntry(
url="https://example.com/catalog.json",
name="test",
priority=1,
install_allowed=True,
)
with pytest.raises(WorkflowCatalogError, match="exceeds maximum size"):
catalog._fetch_single_catalog(entry, force_refresh=True)
assert requested_sizes
assert not catalog.cache_dir.exists()
def test_add_catalog(self, project_dir):
from specify_cli.workflows.catalog import WorkflowCatalog
@@ -7002,6 +7059,7 @@ class TestStepCatalog:
[
"https://[::1", # unterminated IPv6 bracket
"https://[not-an-ip]/x", # bracketed non-IP host
"https://example.com:notaport/steps.json",
],
)
def test_validate_url_malformed_raises_validation_error(self, project_dir, url):
@@ -7103,6 +7161,62 @@ class TestStepCatalog:
catalog._fetch_single_catalog(entry, force_refresh=True)
assert captured["rv"] is not None
def test_fetch_rejects_oversized_catalog_response(
self, project_dir, monkeypatch
):
from specify_cli.authentication import http as auth_http
from specify_cli.workflows import catalog as catalog_module
from specify_cli.workflows.catalog import (
StepCatalog,
StepCatalogEntry,
StepCatalogError,
)
monkeypatch.setattr(catalog_module, "MAX_JSON_CATALOG_BYTES", 32)
requested_sizes: list[int] = []
class _FakeResponse:
def __init__(self):
self.body = b"x" * 64
self.offset = 0
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def geturl(self):
return "https://example.com/steps.json"
def read(self, size=-1):
requested_sizes.append(size)
assert size >= 0
chunk_size = min(size, 7)
chunk = self.body[self.offset : self.offset + chunk_size]
self.offset += len(chunk)
return chunk
monkeypatch.setattr(
auth_http,
"open_url",
lambda url, timeout=30, redirect_validator=None: _FakeResponse(),
)
catalog = StepCatalog(project_dir)
entry = StepCatalogEntry(
url="https://example.com/steps.json",
name="test",
priority=1,
install_allowed=True,
)
with pytest.raises(StepCatalogError, match="exceeds maximum size"):
catalog._fetch_single_catalog(entry, force_refresh=True)
assert requested_sizes
assert not catalog.cache_dir.exists()
def test_add_catalog(self, project_dir):
from specify_cli.workflows.catalog import StepCatalog
@@ -8415,6 +8529,269 @@ class TestWorkflowStepAddCLI:
project_dir / ".specify" / "workflows" / "steps" / "my-step"
).exists()
@pytest.mark.parametrize(
("catalog_fields", "expected"),
[
({"url": 123}, "malformed step.yml URL"),
(
{
"step_yml_url": [],
"url": "https://example.com/step.yml",
},
"malformed step.yml URL",
),
(
{
"url": "https://example.com/step.yml",
"init_url": 123,
},
"malformed __init__.py URL",
),
],
)
def test_add_rejects_non_string_required_urls_before_network(
self, project_dir, monkeypatch, catalog_fields, expected
):
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.authentication import http as auth_http
from specify_cli.workflows.catalog import StepCatalog
monkeypatch.chdir(project_dir)
monkeypatch.setattr(
StepCatalog,
"get_step_info",
lambda self, step_id: {
"id": step_id,
"name": "Test Step",
"_install_allowed": True,
**catalog_fields,
},
)
monkeypatch.setattr(
auth_http,
"open_url",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("download should not start")
),
)
result = CliRunner().invoke(
app, ["workflow", "step", "add", "my-step"]
)
assert result.exit_code != 0
assert result.exception is None or isinstance(result.exception, SystemExit)
assert expected in result.output
assert not (
project_dir / ".specify" / "workflows" / "steps" / "my-step"
).exists()
@pytest.mark.parametrize(
("alias", "protected_name"),
[
("./step.yml", "step.yml"),
("step.yml/", "step.yml"),
("STEP.YML", "step.yml"),
(".\\step.yml", "step.yml"),
("./__init__.py", "__init__.py"),
("__init__.py/", "__init__.py"),
("__INIT__.PY", "__init__.py"),
(".\\__init__.py", "__init__.py"),
],
)
def test_add_does_not_overwrite_required_files_through_path_aliases(
self, project_dir, monkeypatch, alias, protected_name
):
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.authentication import http as auth_http
from specify_cli.workflows.catalog import StepCatalog
monkeypatch.chdir(project_dir)
alias_url = "https://example.com/overwrite"
monkeypatch.setattr(
StepCatalog,
"get_step_info",
lambda self, step_id: {
"id": step_id,
"name": "Test Step",
"url": "https://example.com/step.yml",
"init_url": "https://example.com/__init__.py",
"_install_allowed": True,
"extra_files": {alias: alias_url},
},
)
bodies = {
"https://example.com/step.yml": b"step:\n type_key: my-step\n",
"https://example.com/__init__.py": b"# trusted init\n",
}
requested_urls: list[str] = []
class _FakeResponse:
def __init__(self, url):
self.url = url
self.body = bodies[url]
self.offset = 0
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def geturl(self):
return self.url
def read(self, size=-1):
if size < 0:
size = len(self.body) - self.offset
chunk = self.body[self.offset : self.offset + size]
self.offset += len(chunk)
return chunk
def fake_open_url(url, timeout=30, redirect_validator=None):
requested_urls.append(url)
return _FakeResponse(url)
monkeypatch.setattr(auth_http, "open_url", fake_open_url)
result = CliRunner().invoke(
app, ["workflow", "step", "add", "my-step"]
)
assert result.exit_code == 0, result.output
assert alias_url not in requested_urls
installed_dir = (
project_dir / ".specify" / "workflows" / "steps" / "my-step"
)
assert (installed_dir / protected_name).read_bytes() == bodies[
f"https://example.com/{protected_name}"
]
def test_add_rejects_too_many_package_files_before_network(
self, project_dir, monkeypatch
):
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.authentication import http as auth_http
from specify_cli.workflows import _commands as workflow_commands
from specify_cli.workflows.catalog import StepCatalog
monkeypatch.chdir(project_dir)
monkeypatch.setattr(workflow_commands, "_MAX_STEP_PACKAGE_FILES", 3)
monkeypatch.setattr(
StepCatalog,
"get_step_info",
lambda self, step_id: {
"id": step_id,
"name": "Test Step",
"url": "https://example.com/step.yml",
"init_url": "https://example.com/__init__.py",
"_install_allowed": True,
"extra_files": {
"one.py": "https://example.com/one.py",
"two.py": "https://example.com/two.py",
},
},
)
monkeypatch.setattr(
auth_http,
"open_url",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("download should not start")
),
)
result = CliRunner().invoke(
app, ["workflow", "step", "add", "my-step"]
)
assert result.exit_code != 0
assert result.exception is None or isinstance(result.exception, SystemExit)
assert "exceeding the 3-file limit" in result.output
steps_dir = project_dir / ".specify" / "workflows" / "steps"
assert not (steps_dir / "my-step").exists()
assert list(steps_dir.glob("speckit_step_tmp_*")) == []
def test_add_rejects_package_over_cumulative_size_and_cleans_staging(
self, project_dir, monkeypatch
):
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.authentication import http as auth_http
from specify_cli.workflows import _commands as workflow_commands
from specify_cli.workflows.catalog import StepCatalog
monkeypatch.chdir(project_dir)
monkeypatch.setattr(workflow_commands, "_MAX_STEP_PACKAGE_BYTES", 40)
monkeypatch.setattr(
StepCatalog,
"get_step_info",
lambda self, step_id: {
"id": step_id,
"name": "Test Step",
"url": "https://example.com/step.yml",
"init_url": "https://example.com/__init__.py",
"_install_allowed": True,
"extra_files": {
"helper.py": "https://example.com/helper.py",
},
},
)
bodies = {
"https://example.com/step.yml": b"step:\n type_key: my-step\n",
"https://example.com/__init__.py": b"# init\n",
"https://example.com/helper.py": b"0123456789",
}
class _FakeResponse:
def __init__(self, url):
self.url = url
self.body = bodies[url]
self.offset = 0
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def getheader(self, name):
return None
def geturl(self):
return self.url
def read(self, size=-1):
if size < 0:
size = len(self.body) - self.offset
chunk = self.body[self.offset : self.offset + size]
self.offset += len(chunk)
return chunk
monkeypatch.setattr(
auth_http,
"open_url",
lambda url, timeout=30, redirect_validator=None: _FakeResponse(url),
)
result = CliRunner().invoke(
app, ["workflow", "step", "add", "my-step"]
)
assert result.exit_code != 0
assert result.exception is None or isinstance(result.exception, SystemExit)
assert "40-byte total size limit" in result.output
steps_dir = project_dir / ".specify" / "workflows" / "steps"
assert not (steps_dir / "my-step").exists()
assert list(steps_dir.glob("speckit_step_tmp_*")) == []
def test_add_rejects_non_string_extra_files_key(self, project_dir, monkeypatch):
from typer.testing import CliRunner
from specify_cli import app

View File

@@ -1,19 +1,59 @@
"""Unit tests for malformed download-URL handling in bundle manifest resolution."""
from __future__ import annotations
import hashlib
import io
from types import SimpleNamespace
import pytest
import yaml
from specify_cli.bundler import BundlerError
from specify_cli.bundler.models.catalog import CatalogEntry
from specify_cli.commands import bundle as bundle_commands
from specify_cli.commands.bundle import _download_manifest, _require_https
from tests.bundler_helpers import catalog_entry_dict, valid_manifest_dict
_MALFORMED_URLS = [
"https://[::1", # unclosed IPv6 bracket
"https://[not-an-ip]/bundle.yml",
"https://example.com:notaport/bundle.yml",
"https://example.com:70000/bundle.yml",
]
class _Response(io.BytesIO):
def __init__(self, body: bytes, url: str) -> None:
super().__init__(body)
self._url = url
def geturl(self) -> str:
return self._url
def _resolved_entry(**overrides) -> SimpleNamespace:
entry = CatalogEntry.from_dict(
catalog_entry_dict(
"demo-bundle",
download_url="https://example.com/demo-bundle.yml",
**overrides,
)
)
return SimpleNamespace(entry=entry)
def _patch_download(monkeypatch, body: bytes) -> None:
def fake_open_url(
url,
timeout=10,
extra_headers=None,
redirect_validator=None,
):
return _Response(body, url)
monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url)
@pytest.mark.parametrize("url", _MALFORMED_URLS)
def test_download_manifest_rejects_malformed_url_cleanly(url):
"""A malformed download_url must raise BundlerError, not a raw ValueError.
@@ -40,3 +80,83 @@ def test_require_https_rejects_malformed_url_cleanly(url):
"""
with pytest.raises(BundlerError):
_require_https("bundle 'x'", url)
def test_download_manifest_bounds_remote_artifact(monkeypatch):
body = yaml.safe_dump(valid_manifest_dict()).encode()
_patch_download(monkeypatch, body)
monkeypatch.setattr(bundle_commands, "MAX_DOWNLOAD_BYTES", len(body) - 1)
with pytest.raises(BundlerError, match="exceeds maximum size"):
_download_manifest(_resolved_entry(), offline=False)
def test_download_manifest_accepts_matching_sha256(monkeypatch):
body = yaml.safe_dump(valid_manifest_dict()).encode()
digest = hashlib.sha256(body).hexdigest()
_patch_download(monkeypatch, body)
manifest = _download_manifest(
_resolved_entry(sha256=f"sha256:{digest}"),
offline=False,
)
assert manifest.bundle.id == "demo-bundle"
def test_download_manifest_accepts_legacy_entry_without_sha256(monkeypatch):
body = yaml.safe_dump(valid_manifest_dict()).encode()
_patch_download(monkeypatch, body)
resolved = SimpleNamespace(
entry=SimpleNamespace(
id="demo-bundle",
version="1.2.0",
download_url="https://example.com/demo-bundle.yml",
)
)
manifest = _download_manifest(resolved, offline=False)
assert manifest.bundle.version == "1.2.0"
@pytest.mark.parametrize("declared", ["0" * 64, "not-a-sha256"])
def test_download_manifest_rejects_bad_sha256(monkeypatch, declared):
body = yaml.safe_dump(valid_manifest_dict()).encode()
_patch_download(monkeypatch, body)
with pytest.raises(BundlerError, match="sha256|Integrity check"):
_download_manifest(
_resolved_entry(sha256=declared),
offline=False,
)
@pytest.mark.parametrize(
("field", "value", "message"),
[
("id", "other-bundle", "id mismatch"),
("version", "9.9.9", "version mismatch"),
],
)
def test_download_manifest_rejects_catalog_identity_mismatch(
monkeypatch,
field,
value,
message,
):
data = valid_manifest_dict()
data["bundle"][field] = value
_patch_download(monkeypatch, yaml.safe_dump(data).encode())
with pytest.raises(BundlerError, match=message):
_download_manifest(_resolved_entry(), offline=False)
def test_download_manifest_rejects_invalid_structure(monkeypatch):
data = valid_manifest_dict()
data["bundle"]["author"] = ""
_patch_download(monkeypatch, yaml.safe_dump(data).encode())
with pytest.raises(BundlerError, match="invalid bundle manifest"):
_download_manifest(_resolved_entry(), offline=False)

View File

@@ -20,6 +20,7 @@ def _source(url: str) -> CatalogSource:
class _FakeResponse:
def __init__(self, body: bytes, final_url: str) -> None:
self._body = body
self._offset = 0
self._final_url = final_url
def __enter__(self) -> "_FakeResponse":
@@ -31,8 +32,12 @@ class _FakeResponse:
def geturl(self) -> str:
return self._final_url
def read(self) -> bytes:
return self._body
def read(self, size: int = -1) -> bytes:
if size < 0:
size = len(self._body) - self._offset
start = self._offset
self._offset = min(len(self._body), self._offset + size)
return self._body[start:self._offset]
def test_http_fetch_uses_shared_client_and_rejects_redirect_downgrade(monkeypatch):
@@ -71,6 +76,34 @@ def test_http_fetch_rejects_non_https_final_url(monkeypatch):
fetcher(_source("https://example.com/c.json"))
def test_http_fetch_bounds_catalog_response(monkeypatch):
body = b'{"schema_version":"1.0","bundles":{}}'
def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None):
return _FakeResponse(body, url)
monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url)
monkeypatch.setattr(adapters, "MAX_JSON_CATALOG_BYTES", len(body) - 1)
fetcher = adapters.make_catalog_fetcher(allow_network=True)
with pytest.raises(BundlerError, match="exceeds maximum size"):
fetcher(_source("https://example.com/c.json"))
@pytest.mark.parametrize(
"url",
[
"https://[::1",
"https://example.com:notaport/catalog.json",
"https://example.com:70000/catalog.json",
],
)
def test_fetch_rejects_malformed_source_url_cleanly(url):
fetcher = adapters.make_catalog_fetcher(allow_network=True)
with pytest.raises(BundlerError, match="URL is malformed"):
fetcher(_source(url))
def test_builtin_community_catalog_fetches_repository_catalog_online(monkeypatch):
captured: dict = {}