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:
eldar702
2026-05-10 15:01:28 +03:00
parent 61a7aeab2a
commit ba9b252722
3 changed files with 128 additions and 4 deletions

View File

@@ -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)

View File

@@ -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}")