301 lines
9.4 KiB
Python
301 lines
9.4 KiB
Python
#!/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())
|