document_sorter/nonwork_execution_preflight.py
2026-09-16 13:10:15 +02:00

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