mirror of
https://github.com/github/spec-kit.git
synced 2026-07-07 06:35:06 +08:00
* support controlled multi-install integrations * fix: harden multi-install integration state * refactor: isolate integration runtime helpers * fix: address copilot review feedback * fix: address follow-up copilot feedback * fix: tighten integration switch semantics * fix: address final copilot review feedback * fix: harden integration manifest read errors * fix: refuse symlinked shared infra paths * test: filter expected self-test preset warning * test: address copilot review nits * refactor: centralize safe shared infra writes * fix: use no-follow writes for shared infra * fix: keep default integration atomic on template refresh * fix: harden shared infra error paths * fix: preflight shared infra and future state schemas * fix: support nested shared scripts during preflight * test: tolerate wrapped schema error output * fix: use safe default mode for shared text writes * fix: use posix paths in shared skip output * fix: share project guard for integration use * fix: centralize spec-kit project guards * fix: use posix project paths in cli output * fix: harden shared manifest and upgrade refresh
331 lines
12 KiB
Python
331 lines
12 KiB
Python
"""Hash-tracked installation manifest for integrations.
|
|
|
|
Each installed integration records the files it created together with
|
|
their SHA-256 hashes. On uninstall only files whose hash still matches
|
|
the recorded value are removed — modified files are left in place and
|
|
reported to the caller.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import tempfile
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
def _sha256(path: Path) -> str:
|
|
"""Return the hex SHA-256 digest of *path*."""
|
|
h = hashlib.sha256()
|
|
with open(path, "rb") as fh:
|
|
for chunk in iter(lambda: fh.read(8192), b""):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def _validate_rel_path(rel: Path, root: Path) -> Path:
|
|
"""Resolve *rel* against *root* and verify it stays within *root*.
|
|
|
|
Raises ``ValueError`` if *rel* is absolute, contains ``..`` segments
|
|
that escape *root*, or otherwise resolves outside the project root.
|
|
"""
|
|
if rel.is_absolute():
|
|
raise ValueError(
|
|
f"Absolute paths are not allowed in manifests: {rel}"
|
|
)
|
|
resolved = (root / rel).resolve()
|
|
root_resolved = root.resolve()
|
|
try:
|
|
resolved.relative_to(root_resolved)
|
|
except ValueError:
|
|
raise ValueError(
|
|
f"Path {rel} resolves to {resolved} which is outside "
|
|
f"the project root {root_resolved}"
|
|
) from None
|
|
return resolved
|
|
|
|
|
|
def _manifest_path_label(root: Path, path: Path) -> str:
|
|
try:
|
|
return path.relative_to(root).as_posix()
|
|
except ValueError:
|
|
return path.as_posix()
|
|
|
|
|
|
def _ensure_safe_manifest_directory(root: Path, directory: Path) -> None:
|
|
"""Create a manifest directory without following symlinked parents."""
|
|
root_resolved = root.resolve()
|
|
try:
|
|
rel = directory.relative_to(root)
|
|
except ValueError:
|
|
label = _manifest_path_label(root, directory)
|
|
raise ValueError(f"Integration manifest directory escapes project root: {label}") from None
|
|
|
|
current = root
|
|
for part in rel.parts:
|
|
current = current / part
|
|
label = _manifest_path_label(root, current)
|
|
if current.is_symlink():
|
|
raise ValueError(f"Refusing to use symlinked integration manifest directory: {label}")
|
|
if current.exists():
|
|
if not current.is_dir():
|
|
raise ValueError(f"Integration manifest directory path is not a directory: {label}")
|
|
try:
|
|
current.resolve().relative_to(root_resolved)
|
|
except (OSError, ValueError):
|
|
raise ValueError(f"Integration manifest directory escapes project root: {label}") from None
|
|
continue
|
|
current.mkdir()
|
|
try:
|
|
current.resolve().relative_to(root_resolved)
|
|
except (OSError, ValueError):
|
|
raise ValueError(f"Integration manifest directory escapes project root: {label}") from None
|
|
|
|
|
|
def _ensure_safe_manifest_destination(root: Path, path: Path) -> None:
|
|
"""Refuse manifest writes that would escape the project or follow symlinks."""
|
|
root_resolved = root.resolve()
|
|
_ensure_safe_manifest_directory(root, path.parent)
|
|
label = _manifest_path_label(root, path)
|
|
if path.is_symlink():
|
|
raise ValueError(f"Refusing to overwrite symlinked integration manifest path: {label}")
|
|
if path.exists():
|
|
if not path.is_file():
|
|
raise ValueError(f"Integration manifest path is not a file: {label}")
|
|
try:
|
|
path.resolve().relative_to(root_resolved)
|
|
except (OSError, ValueError):
|
|
raise ValueError(f"Integration manifest path escapes project root: {label}") from None
|
|
|
|
|
|
class IntegrationManifest:
|
|
"""Tracks files installed by a single integration.
|
|
|
|
Parameters:
|
|
key: Integration identifier (e.g. ``"copilot"``).
|
|
project_root: Absolute path to the project directory.
|
|
version: CLI version string recorded in the manifest.
|
|
"""
|
|
|
|
def __init__(self, key: str, project_root: Path, version: str = "") -> None:
|
|
self.key = key
|
|
self.project_root = project_root.resolve()
|
|
self.version = version
|
|
self._files: dict[str, str] = {} # rel_path → sha256 hex
|
|
self._installed_at: str = ""
|
|
|
|
# -- Manifest file location -------------------------------------------
|
|
|
|
@property
|
|
def manifest_path(self) -> Path:
|
|
"""Path to the on-disk manifest JSON."""
|
|
return self.project_root / ".specify" / "integrations" / f"{self.key}.manifest.json"
|
|
|
|
# -- Recording files --------------------------------------------------
|
|
|
|
def record_file(self, rel_path: str | Path, content: bytes | str) -> Path:
|
|
"""Write *content* to *rel_path* (relative to project root) and record its hash.
|
|
|
|
Creates parent directories as needed. Returns the absolute path
|
|
of the written file.
|
|
|
|
Raises ``ValueError`` if *rel_path* resolves outside the project root.
|
|
"""
|
|
rel = Path(rel_path)
|
|
abs_path = _validate_rel_path(rel, self.project_root)
|
|
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
if isinstance(content, str):
|
|
content = content.encode("utf-8")
|
|
abs_path.write_bytes(content)
|
|
|
|
normalized = abs_path.relative_to(self.project_root).as_posix()
|
|
self._files[normalized] = hashlib.sha256(content).hexdigest()
|
|
return abs_path
|
|
|
|
def record_existing(self, rel_path: str | Path) -> None:
|
|
"""Record the hash of an already-existing file at *rel_path*.
|
|
|
|
Raises ``ValueError`` if *rel_path* resolves outside the project root.
|
|
"""
|
|
rel = Path(rel_path)
|
|
abs_path = _validate_rel_path(rel, self.project_root)
|
|
normalized = abs_path.relative_to(self.project_root).as_posix()
|
|
self._files[normalized] = _sha256(abs_path)
|
|
|
|
# -- Querying ---------------------------------------------------------
|
|
|
|
@property
|
|
def files(self) -> dict[str, str]:
|
|
"""Return a copy of the ``{rel_path: sha256}`` mapping."""
|
|
return dict(self._files)
|
|
|
|
def check_modified(self) -> list[str]:
|
|
"""Return relative paths of tracked files whose content changed on disk."""
|
|
modified: list[str] = []
|
|
for rel, expected_hash in self._files.items():
|
|
rel_path = Path(rel)
|
|
# Skip paths that are absolute or attempt to escape the project root
|
|
if rel_path.is_absolute() or ".." in rel_path.parts:
|
|
continue
|
|
abs_path = self.project_root / rel_path
|
|
if not abs_path.exists() and not abs_path.is_symlink():
|
|
continue
|
|
# Treat symlinks and non-regular-files as modified
|
|
if abs_path.is_symlink() or not abs_path.is_file():
|
|
modified.append(rel)
|
|
continue
|
|
if _sha256(abs_path) != expected_hash:
|
|
modified.append(rel)
|
|
return modified
|
|
|
|
# -- Uninstall --------------------------------------------------------
|
|
|
|
def uninstall(
|
|
self,
|
|
project_root: Path | None = None,
|
|
*,
|
|
force: bool = False,
|
|
) -> tuple[list[Path], list[Path]]:
|
|
"""Remove tracked files whose hash still matches.
|
|
|
|
Parameters:
|
|
project_root: Override for the project root.
|
|
force: If ``True``, remove files even if modified.
|
|
|
|
Returns:
|
|
``(removed, skipped)`` — absolute paths.
|
|
"""
|
|
root = (project_root or self.project_root).resolve()
|
|
removed: list[Path] = []
|
|
skipped: list[Path] = []
|
|
|
|
for rel, expected_hash in self._files.items():
|
|
# Use non-resolved path for deletion so symlinks themselves
|
|
# are removed, not their targets.
|
|
path = root / rel
|
|
# Validate containment lexically (without following symlinks)
|
|
# by collapsing .. segments via Path resolution on the string parts.
|
|
try:
|
|
normed = Path(os.path.normpath(path))
|
|
normed.relative_to(root)
|
|
except (ValueError, OSError):
|
|
continue
|
|
if not path.exists() and not path.is_symlink():
|
|
continue
|
|
# Skip directories — manifest only tracks files
|
|
if not path.is_file() and not path.is_symlink():
|
|
skipped.append(path)
|
|
continue
|
|
# Never follow symlinks when comparing hashes. Only remove
|
|
# symlinks when forced, to avoid acting on tampered entries.
|
|
if path.is_symlink():
|
|
if not force:
|
|
skipped.append(path)
|
|
continue
|
|
else:
|
|
if not force and _sha256(path) != expected_hash:
|
|
skipped.append(path)
|
|
continue
|
|
try:
|
|
path.unlink()
|
|
except OSError:
|
|
skipped.append(path)
|
|
continue
|
|
removed.append(path)
|
|
# Clean up empty parent directories up to project root
|
|
parent = path.parent
|
|
while parent != root:
|
|
try:
|
|
parent.rmdir() # only succeeds if empty
|
|
except OSError:
|
|
break
|
|
parent = parent.parent
|
|
|
|
# Remove the manifest file itself
|
|
manifest = root / ".specify" / "integrations" / f"{self.key}.manifest.json"
|
|
if manifest.exists():
|
|
manifest.unlink()
|
|
parent = manifest.parent
|
|
while parent != root:
|
|
try:
|
|
parent.rmdir()
|
|
except OSError:
|
|
break
|
|
parent = parent.parent
|
|
|
|
return removed, skipped
|
|
|
|
# -- Persistence ------------------------------------------------------
|
|
|
|
def save(self) -> Path:
|
|
"""Write the manifest to disk. Returns the manifest path."""
|
|
self._installed_at = self._installed_at or datetime.now(timezone.utc).isoformat()
|
|
data: dict[str, Any] = {
|
|
"integration": self.key,
|
|
"version": self.version,
|
|
"installed_at": self._installed_at,
|
|
"files": self._files,
|
|
}
|
|
path = self.manifest_path
|
|
content = json.dumps(data, indent=2) + "\n"
|
|
_ensure_safe_manifest_destination(self.project_root, path)
|
|
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
temp_path = Path(temp_name)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
fh.write(content)
|
|
temp_path.chmod(0o644)
|
|
_ensure_safe_manifest_destination(self.project_root, path)
|
|
os.replace(temp_path, path)
|
|
finally:
|
|
if temp_path.exists():
|
|
temp_path.unlink()
|
|
return path
|
|
|
|
@classmethod
|
|
def load(cls, key: str, project_root: Path) -> IntegrationManifest:
|
|
"""Load an existing manifest from disk.
|
|
|
|
Raises ``FileNotFoundError`` if the manifest does not exist.
|
|
"""
|
|
inst = cls(key, project_root)
|
|
path = inst.manifest_path
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(
|
|
f"Integration manifest at {path} contains invalid JSON"
|
|
) from exc
|
|
|
|
if not isinstance(data, dict):
|
|
raise ValueError(
|
|
f"Integration manifest at {path} must be a JSON object, "
|
|
f"got {type(data).__name__}"
|
|
)
|
|
|
|
files = data.get("files", {})
|
|
if not isinstance(files, dict) or not all(
|
|
isinstance(k, str) and isinstance(v, str) for k, v in files.items()
|
|
):
|
|
raise ValueError(
|
|
f"Integration manifest 'files' at {path} must be a "
|
|
"mapping of string paths to string hashes"
|
|
)
|
|
|
|
inst.version = data.get("version", "")
|
|
inst._installed_at = data.get("installed_at", "")
|
|
inst._files = files
|
|
|
|
stored_key = data.get("integration", "")
|
|
if stored_key and stored_key != key:
|
|
raise ValueError(
|
|
f"Manifest at {path} belongs to integration {stored_key!r}, "
|
|
f"not {key!r}"
|
|
)
|
|
|
|
return inst
|