← Files Tahr SecurityARCHIVED FILE
skills/tahr-threat-model-app/scripts/build_repository_manifest.py
7.62 KB · Oct 5, 2026 · 18:35 UTC
#!/usr/bin/env python3
"""Build a deterministic, content-hashed manifest for a full threat-model review."""
from __future__ import annotations
import argparse
import fnmatch
import hashlib
import json
import os
import tempfile
from pathlib import Path, PurePosixPath
from typing import Any, Optional, Sequence
BUILT_IN_EXCLUSIONS = (".git",)
def _within(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def _relative(path: Path, root: Path) -> str:
return path.relative_to(root).as_posix()
def _matches_exclusion(relative_path: str, patterns: Sequence[str]) -> bool:
path = PurePosixPath(relative_path)
for raw_pattern in patterns:
pattern = raw_pattern.strip().replace("\\", "/").rstrip("/")
if not pattern:
continue
if relative_path == pattern or relative_path.startswith(pattern + "/"):
return True
if fnmatch.fnmatchcase(relative_path, pattern) or path.match(pattern):
return True
return False
def _file_digest(path: Path) -> tuple[int, str]:
digest = hashlib.sha256()
size = 0
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
size += len(chunk)
digest.update(chunk)
return size, digest.hexdigest()
def _entry(path: Path, root: Path) -> dict[str, Any]:
relative_path = _relative(path, root)
if path.is_symlink():
target = os.readlink(path)
encoded = target.encode("utf-8", errors="surrogateescape")
return {
"path": relative_path,
"type": "symlink",
"size": len(encoded),
"sha256": hashlib.sha256(encoded).hexdigest(),
"target": target,
}
size, digest = _file_digest(path)
return {"path": relative_path, "type": "file", "size": size, "sha256": digest}
def build_manifest(
root: Path,
includes: Sequence[str],
excludes: Sequence[str],
output: Path,
revision: Optional[str],
) -> tuple[dict[str, Any], bytes]:
root = root.resolve(strict=True)
if not root.is_dir():
raise ValueError(f"repository root is not a directory: {root}")
output_resolved = output.resolve(strict=False)
effective_exclusions = list(BUILT_IN_EXCLUSIONS) + list(excludes)
generated_output: Optional[str] = None
if _within(output_resolved, root):
generated_output = (
_relative(output_resolved.parent, root)
if output_resolved.parent != root
else _relative(output_resolved, root)
)
effective_exclusions.append(generated_output)
normalized_includes: list[str] = []
entries: dict[str, dict[str, Any]] = {}
for raw_include in includes:
candidate = Path(os.path.abspath(root / raw_include))
if not _within(candidate, root):
raise ValueError(f"included path escapes repository root: {raw_include}")
if not candidate.exists() and not candidate.is_symlink():
raise ValueError(f"included path does not exist: {raw_include}")
relative_include = _relative(candidate, root) if candidate != root else "."
if candidate.is_dir() and relative_include != ".":
relative_include += "/"
if relative_include not in normalized_includes:
normalized_includes.append(relative_include)
if candidate.is_file() or candidate.is_symlink():
relative_path = _relative(candidate, root)
if not _matches_exclusion(relative_path, effective_exclusions):
entries[relative_path] = _entry(candidate, root)
continue
for current_root, directory_names, file_names in os.walk(candidate, followlinks=False):
current = Path(current_root)
kept_directories: list[str] = []
for name in sorted(directory_names):
child = current / name
relative_path = _relative(child, root)
if _matches_exclusion(relative_path, effective_exclusions):
continue
if child.is_symlink():
entries[relative_path] = _entry(child, root)
else:
kept_directories.append(name)
directory_names[:] = kept_directories
for name in sorted(file_names):
child = current / name
relative_path = _relative(child, root)
if not _matches_exclusion(relative_path, effective_exclusions):
entries[relative_path] = _entry(child, root)
if not entries:
raise ValueError("the admitted inventory contains no files or symlinks")
normalized_includes.sort()
normalized_excludes = sorted({item.replace("\\", "/").rstrip("/") for item in excludes if item.strip()})
sorted_entries = [entries[key] for key in sorted(entries)]
scope_payload = {
"included_paths": normalized_includes,
"excluded_patterns": normalized_excludes,
"built_in_exclusions": list(BUILT_IN_EXCLUSIONS),
"generated_output_exclusion": generated_output,
"entries": sorted_entries,
}
scope_bytes = json.dumps(scope_payload, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
content_hash = "sha256:" + hashlib.sha256(scope_bytes).hexdigest()
bound_revision = revision or "snapshot-" + content_hash
manifest = {
"manifest_version": "1.0.0",
"repository_name": root.name,
"revision": bound_revision,
# Hash the canonical admitted scope payload rather than the serialized
# manifest, which cannot safely contain a hash of itself.
"content_hash": content_hash,
**scope_payload,
}
output_bytes = (json.dumps(manifest, sort_keys=True, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
return manifest, output_bytes
def _write_atomic(path: Path, content: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=path.parent)
try:
with os.fdopen(descriptor, "wb") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary_name, path)
except BaseException:
try:
os.unlink(temporary_name)
except FileNotFoundError:
pass
raise
def main(argv: Optional[Sequence[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("root", type=Path, help="repository root to inventory")
parser.add_argument("--include", action="append", default=[], help="admitted repository-relative file or directory; repeat as needed (default: .)")
parser.add_argument("--exclude", action="append", default=[], help="repository-relative path or glob to exclude; repeat as needed")
parser.add_argument("--revision", help="immutable VCS/release revision; omit to derive snapshot-sha256 from admitted content")
parser.add_argument("--output", required=True, type=Path, help="manifest JSON output path")
args = parser.parse_args(argv)
try:
manifest, content = build_manifest(args.root, args.include or ["."], args.exclude, args.output, args.revision)
_write_atomic(args.output, content)
except (OSError, ValueError) as exc:
print(f"ERROR: {exc}")
return 1
print(f"revision={manifest['revision']}")
print(f"content_hash={manifest['content_hash']}")
print(f"entries={len(manifest['entries'])}")
print(f"output={args.output.resolve()}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: 7003584fd536f6facb816a5983f17259ee1696a232179b3f3cbe7eb67593914d