#!/usr/bin/env python3 """ Read-only execution preflight for the non-work recovery routing plan. Checks: - routing manifest is structurally valid - every source file still exists - every source SHA-256 still matches the approved manifest - every COPY_NEW destination is still absent - COPY_NEW destinations remain inside the approved recovery folders - no two COPY_NEW rows collide on the same destination - every SKIP_EXACT_DUPLICATE target still exists and still has the same SHA-256 - no HOLD/ERROR actions are present This script NEVER creates directories, copies files, moves files, renames files, deletes files, or modifies Syncthing state. """ from __future__ import annotations import argparse import csv import hashlib import os import sys from collections import Counter from pathlib import Path CHUNK = 1024 * 1024 ALLOWED_COPY_BASES = ( Path("/data/03_PRIVAT/01_INBOX/Recovered_Work-PC_2026-09-14"), Path("/data/01_INBOX/Recovered_Work-PC_2026-09-14"), ) ALLOWED_SKIP_ROOTS = ( Path("/data/03_PRIVAT"), Path("/data/01_INBOX"), ) def sha256_file(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as f: for chunk in iter(lambda: f.read(CHUNK), b""): h.update(chunk) return h.hexdigest() def is_under(path: Path, root: Path) -> bool: try: path.resolve(strict=False).relative_to(root.resolve(strict=False)) return True except ValueError: return False def read_manifest(path: Path) -> list[dict[str, str]]: with path.open("r", encoding="utf-8", newline="") as f: rows = list(csv.DictReader(f, delimiter="\t")) if not rows: raise RuntimeError("Routing manifest is empty.") required = { "effective_scope", "relative_path", "filename", "source_path", "sha256", "audit_status", "dry_run_action", "proposed_destination", "source_delete", } missing = required - set(rows[0]) if missing: raise RuntimeError( "Routing manifest is missing columns: " + ", ".join(sorted(missing)) ) return rows def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--manifest", required=True, type=Path) args = ap.parse_args() manifest = args.manifest.resolve() rows = read_manifest(manifest) print("=== NON-WORK ROUTING EXECUTION PREFLIGHT ===") print(f"Manifest: {manifest}") print(f"Rows: {len(rows)}") print() problems: list[str] = [] warnings: list[str] = [] checked = Counter() copy_destinations: dict[Path, str] = {} for i, row in enumerate(rows, 1): action = row["dry_run_action"].strip() source = Path(row["source_path"]) expected_hash = row["sha256"].strip().lower() destination_text = row["proposed_destination"].strip() label = f"[{i:>2}/{len(rows)}] {action:<22} {row['filename']}" # Safety invariant from the approved dry-run manifest. if row["source_delete"].strip().upper() != "NO": problems.append( f"{row['relative_path']}: source_delete is not NO." ) # Every row's staging source must still exist and match. if not source.is_file(): problems.append( f"{row['relative_path']}: source missing: {source}" ) print(label + " SOURCE_MISSING") continue try: actual_hash = sha256_file(source) except Exception as exc: problems.append( f"{row['relative_path']}: cannot hash source: " f"{type(exc).__name__}: {exc}" ) print(label + " SOURCE_HASH_ERROR") continue if actual_hash.lower() != expected_hash: problems.append( f"{row['relative_path']}: source hash changed; " f"expected {expected_hash}, got {actual_hash}" ) print(label + " SOURCE_HASH_MISMATCH") continue checked["source_hash_ok"] += 1 if action == "COPY_NEW": if not destination_text: problems.append( f"{row['relative_path']}: COPY_NEW has no destination." ) print(label + " DESTINATION_MISSING") continue dest = Path(destination_text) if not any(is_under(dest, base) for base in ALLOWED_COPY_BASES): problems.append( f"{row['relative_path']}: COPY_NEW destination is outside " f"approved recovery folders: {dest}" ) print(label + " DESTINATION_OUTSIDE_APPROVED_BASE") continue normalized = dest.resolve(strict=False) if normalized in copy_destinations: problems.append( f"Destination collision: {dest} is proposed for both " f"{copy_destinations[normalized]!r} and " f"{row['relative_path']!r}" ) print(label + " DESTINATION_COLLISION") continue copy_destinations[normalized] = row["relative_path"] if os.path.lexists(dest): problems.append( f"{row['relative_path']}: proposed destination now exists: " f"{dest}" ) print(label + " DESTINATION_ALREADY_EXISTS") continue checked["copy_new_ready"] += 1 print(label + " READY") elif action == "SKIP_EXACT_DUPLICATE": if not destination_text: problems.append( f"{row['relative_path']}: duplicate row has no target path." ) print(label + " DUPLICATE_TARGET_MISSING") continue # The dry-run format allows multiple exact target matches separated # with " | ". Every listed target must still be identical. targets = [Path(p) for p in destination_text.split(" | ") if p] if not targets: problems.append( f"{row['relative_path']}: no duplicate targets parsed." ) print(label + " DUPLICATE_TARGET_MISSING") continue row_ok = True for target in targets: if not any(is_under(target, root) for root in ALLOWED_SKIP_ROOTS): problems.append( f"{row['relative_path']}: duplicate target outside " f"approved roots: {target}" ) row_ok = False continue if not target.is_file(): problems.append( f"{row['relative_path']}: duplicate target missing: " f"{target}" ) row_ok = False continue try: target_hash = sha256_file(target) except Exception as exc: problems.append( f"{row['relative_path']}: cannot hash duplicate target " f"{target}: {type(exc).__name__}: {exc}" ) row_ok = False continue if target_hash.lower() != expected_hash: problems.append( f"{row['relative_path']}: duplicate target hash changed: " f"{target}" ) row_ok = False if row_ok: checked["skip_duplicate_verified"] += 1 print(label + " VERIFIED") else: print(label + " DUPLICATE_VERIFICATION_FAILED") elif action.startswith("HOLD"): problems.append( f"{row['relative_path']}: unresolved HOLD action remains." ) print(label + " HOLD_PRESENT") else: problems.append( f"{row['relative_path']}: unexpected action {action!r}." ) print(label + " UNKNOWN_ACTION") # The recovery directories themselves should still be absent at this stage. print() print("=== RECOVERY FOLDER STATE ===") for base in ALLOWED_COPY_BASES: exists = os.path.lexists(base) print(f"{base}: {'EXISTS' if exists else 'ABSENT'}") if exists: problems.append( f"Recovery folder already exists before apply: {base}" ) print() print("=== SUMMARY ===") print(f"Source hashes verified: {checked['source_hash_ok']}") print(f"COPY_NEW ready: {checked['copy_new_ready']}") print(f"SKIP duplicates re-verified: {checked['skip_duplicate_verified']}") print(f"Problems: {len(problems)}") print(f"Warnings: {len(warnings)}") if warnings: print() print("Warnings:") for item in warnings: print(" -", item) if problems: print() print("PREFLIGHT FAILED — DO NOT APPLY") for item in problems: print(" -", item) return 2 print() print("PREFLIGHT PASS") print("No files or directories were created, copied, moved, renamed, or deleted.") print("Syncthing state was not modified.") return 0 if __name__ == "__main__": raise SystemExit(main())