mirror of
https://github.com/github/spec-kit.git
synced 2026-07-07 06:35:06 +08:00
fix(shared-infra): guard manifest.record_existing against non-file dst
Address Copilot review feedback on PR #2483. The previous fix called ``manifest.record_existing(rel_skip)`` from the skip branch of both loops in ``install_shared_infra``, which would crash with ``IsADirectoryError`` (or another ``OSError``) if a directory or other non-regular-file happened to exist at the expected destination path — since ``record_existing`` opens the file to compute its SHA-256. Three coordinated fixes: 1. ``IntegrationManifest.record_existing`` now validates its precondition: it raises ``ValueError`` if the path is a symlink or is not a regular file. The docstring already promised "an already-existing file"; this enforces it. The symlink check runs on the un-resolved path because ``_validate_rel_path`` calls ``resolve()``, which would silently follow the symlink. Mirrors the existing ``_ensure_safe_manifest_destination`` precedent in the same module. 2. In ``install_shared_infra``'s scripts and templates skip branches, guard the ``record_existing`` call with ``dst.is_file()`` and wrap it in ``try/except (OSError, ValueError)``. A directory collision, permission error, or TOCTOU race no longer aborts the whole install — the user gets a per-path warning, the path still surfaces in ``skipped_files``, and the rest of the install continues. 3. ``_read_manifest_files`` in the regression test no longer falls back to ``data.get("_files")`` (Copilot's low-confidence finding): the silent fallback could mask a schema regression where the public ``files`` key is renamed. It now asserts ``"files" in data`` and that the value is a dict. Add two regression tests in ``TestSpeckitManifestRecordsSkippedFiles`` covering the directory-at-destination edge case for both the scripts loop and the templates loop. Both verify (a) install does not crash, (b) the non-file path is not recorded in the manifest, and (c) the path still surfaces in the user-visible warning. The "shared infrastructure file(s)" warning text is changed to "path(s)" so it remains accurate when non-file entries appear in the list. Refs #2107
This commit is contained in:
@@ -147,12 +147,28 @@ class IntegrationManifest:
|
||||
return abs_path
|
||||
|
||||
def record_existing(self, rel_path: str | Path) -> None:
|
||||
"""Record the hash of an already-existing file at *rel_path*.
|
||||
"""Record the hash of an already-existing regular file at *rel_path*.
|
||||
|
||||
Raises ``ValueError`` if *rel_path* resolves outside the project root.
|
||||
Raises:
|
||||
ValueError: if *rel_path* resolves outside the project root, is
|
||||
a symlink, or is not a regular file. A directory or other
|
||||
non-file path cannot be silently recorded — its hash would
|
||||
be meaningless and ``check_modified``/``uninstall`` would
|
||||
treat the entry as permanently broken.
|
||||
"""
|
||||
rel = Path(rel_path)
|
||||
# Check ``is_symlink()`` on the un-resolved path because
|
||||
# ``_validate_rel_path`` resolves the path (which would follow
|
||||
# the symlink and silently record the target instead).
|
||||
if (self.project_root / rel).is_symlink():
|
||||
raise ValueError(
|
||||
f"Refusing to record symlinked manifest path: {rel}"
|
||||
)
|
||||
abs_path = _validate_rel_path(rel, self.project_root)
|
||||
if not abs_path.is_file():
|
||||
raise ValueError(
|
||||
f"Manifest path is not a regular file: {rel}"
|
||||
)
|
||||
normalized = abs_path.relative_to(self.project_root).as_posix()
|
||||
self._files[normalized] = _sha256(abs_path)
|
||||
|
||||
|
||||
@@ -435,7 +435,7 @@ def install_shared_infra(
|
||||
|
||||
if skipped_files:
|
||||
console.print(
|
||||
f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure file(s) already exist and were not updated:"
|
||||
f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure path(s) already exist and were not updated:"
|
||||
)
|
||||
for path in skipped_files:
|
||||
console.print(f" {path}")
|
||||
|
||||
Reference in New Issue
Block a user