4
import argparse
5
import hashlib
6
import json
7
+import re
8
import shutil
9
from datetime import UTC, datetime
10
from pathlib import Path
12
13
BACKUP_SCHEMA_VERSION = "publish_backup_v1"
14
PUBLISH_MANIFEST_SCHEMA_VERSION = "publish_eligibility_v1"
15
+RAW_STORE_SCHEMA_VERSION = "raw_store_v1"
16
+WEEK_PATTERN = re.compile(r"^[0-9]{4}-W[0-9]{2}$")
17
+SAFE_COMPONENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
18
19
20
def sha256_file(path: Path) -> str | None:
57
return relative, resolved_path
58
59
60
+def require_safe_component(value: str, *, label: str) -> str:
61
+ candidate = value.strip()
62
+ if not candidate or not SAFE_COMPONENT_PATTERN.fullmatch(candidate):
63
+ raise SystemExit(f"Invalid {label}: {value!r}")
64
+ return candidate
65
+
66
+
67
+def require_week(value: str) -> str:
68
+ week = value.strip()
69
+ if not WEEK_PATTERN.fullmatch(week):
70
+ raise SystemExit(f"Invalid week format. Expected YYYY-WNN, got: {value!r}")
71
+ return week
72
+
73
+
74
+def require_raw_path(root: Path, value: str) -> tuple[Path, Path]:
75
+ relative = relpath_under_root(root, value)
76
+ if relative.parts[:2] != ("data", "raw"):
77
+ raise SystemExit(f"Raw store paths must live under data/raw/: {value}")
78
+ return relative, root / relative
79
+
80
+
81
+def is_week_raw_json(path: Path, week: str) -> bool:
82
+ return path.suffix == ".json" and (
83
+ path.name == f"{week}.json" or path.name.startswith(f"{week}-")
84
+ )
85
+
86
+
87
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
88
parser = argparse.ArgumentParser(description="Publish-branch backup and restore safeguards.")
89
subparsers = parser.add_subparsers(dest="command", required=True)
114
restore.add_argument("--root", default=".", type=Path)
115
restore.add_argument("--backup-manifest", required=True, type=Path)
116
117
+ store_raw_parser = subparsers.add_parser(
118
+ "store-raw", help="Store raw evidence under an immutable week/source-run path."
119
+ )
120
+ store_raw_parser.add_argument("--root", default=".", type=Path)
121
+ store_raw_parser.add_argument("--week", required=True)
122
+ store_raw_parser.add_argument("--source-run-id", required=True)
123
+ store_raw_parser.add_argument("--source-artifact-id", required=True)
124
+ store_raw_parser.add_argument("--source-artifact-name", default="raw-data")
125
+ store_raw_parser.add_argument("--source-head-sha", required=True)
126
+ store_raw_parser.add_argument("--store-root", default=Path("data/raw-store"), type=Path)
127
+ store_raw_parser.add_argument(
128
+ "--path",
129
+ action="append",
130
+ required=True,
131
+ help="Week-scoped data/raw file to preserve.",
132
+ )
133
+
134
+ restore_raw_parser = subparsers.add_parser(
135
+ "restore-raw", help="Restore hash-verified raw evidence from a source workflow run."
136
+ )
137
+ restore_raw_parser.add_argument("--root", default=".", type=Path)
138
+ restore_raw_parser.add_argument("--week", required=True)
139
+ restore_raw_parser.add_argument("--source-run-id", required=True)
140
+ restore_raw_parser.add_argument("--store-root", default=Path("data/raw-store"), type=Path)
141
+
142
return parser.parse_args(argv)
143
144
259
return 0
260
261
262
+def store_raw(args: argparse.Namespace) -> int:
263
+ root = args.root.resolve()
264
+ week = require_week(args.week)
265
+ source_run_id = require_safe_component(args.source_run_id, label="source_run_id")
266
+ source_artifact_id = args.source_artifact_id.strip()
267
+ source_artifact_name = args.source_artifact_name.strip()
268
+ source_head_sha = args.source_head_sha.strip()
269
+ if not source_artifact_id:
270
+ raise SystemExit("source_artifact_id is required.")
271
+ if not source_artifact_name:
272
+ raise SystemExit("source_artifact_name is required.")
273
+ if not source_head_sha:
274
+ raise SystemExit("source_head_sha is required.")
275
+
276
+ sources: list[tuple[Path, Path]] = []
277
+ seen_sources: set[Path] = set()
278
+ for raw_path in args.path:
279
+ relative, source = require_raw_path(root, raw_path)
280
+ if relative in seen_sources:
281
+ raise SystemExit(f"Duplicate raw store source: {relative.as_posix()}")
282
+ seen_sources.add(relative)
283
+ if not source.exists() or not source.is_file():
284
+ raise SystemExit(f"Raw store source must be a regular file: {relative.as_posix()}")
285
+ if relative.name != f"{week}.json" and not relative.name.startswith(f"{week}-"):
286
+ raise SystemExit(f"Raw store source does not belong to {week}: {relative.as_posix()}")
287
+ sources.append((relative, source))
288
+
289
+ store_root = root / relpath_under_root(root, args.store_root.as_posix())
290
+ destination = store_root / week / source_run_id
291
+ if destination.exists():
292
+ raise SystemExit(f"Refusing to overwrite immutable raw store: {destination}")
293
+ destination.mkdir(parents=True, exist_ok=False)
294
+
295
+ entries: list[dict[str, Any]] = []
296
+ try:
297
+ for relative, source in sorted(sources):
298
+ stored = destination / "files" / relative
299
+ stored.parent.mkdir(parents=True, exist_ok=True)
300
+ shutil.copy2(source, stored)
301
+ source_hash = sha256_file(source)
302
+ stored_hash = sha256_file(stored)
303
+ if source_hash is None or stored_hash != source_hash:
304
+ raise SystemExit(f"Raw store checksum mismatch while copying {relative.as_posix()}")
305
+ entries.append(
306
+ {
307
+ "week": week,
308
+ "artifact_id": source_artifact_id,
309
+ "source_run_id": source_run_id,
310
+ "head_sha": source_head_sha,
311
+ "original_path": relative.as_posix(),
312
+ "stored_path": stored.relative_to(root).as_posix(),
313
+ "size_bytes": stored.stat().st_size,
314
+ "sha256": stored_hash,
315
+ }
316
+ )
317
+
318
+ manifest = {
319
+ "schema_version": RAW_STORE_SCHEMA_VERSION,
320
+ "created_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
321
+ "week": week,
322
+ "source_run_id": source_run_id,
323
+ "source_artifact": {
324
+ "id": source_artifact_id,
325
+ "name": source_artifact_name,
326
+ "head_sha": source_head_sha,
327
+ "retention_days": 90,
328
+ },
329
+ "files": entries,
330
+ }
331
+ manifest_path = destination / "manifest.json"
332
+ manifest_path.write_text(
333
+ json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
334
+ )
335
+ except BaseException:
336
+ shutil.rmtree(destination, ignore_errors=True)
337
+ raise
338
+
339
+ print(f"Created immutable raw store: {destination.relative_to(root).as_posix()}")
340
+ return 0
341
+
342
+
343
+def validate_raw_store_manifest(
344
+ root: Path,
345
+ manifest_path: Path,
346
+ *,
347
+ expected_week: str,
348
+ expected_source_run_id: str,
349
+) -> tuple[dict[str, Any], list[tuple[Path, Path, dict[str, Any]]]]:
350
+ root = root.resolve()
351
+ week = require_week(expected_week)
352
+ source_run_id = require_safe_component(expected_source_run_id, label="source_run_id")
353
+ manifest_relative, manifest = path_under_root(root, manifest_path)
354
+ payload = load_json(manifest)
355
+ if payload is None:
356
+ raise SystemExit(f"Raw store manifest is missing or malformed: {manifest_relative}")
357
+ if payload.get("schema_version") != RAW_STORE_SCHEMA_VERSION:
358
+ raise SystemExit(f"Unsupported raw store schema: {payload.get('schema_version')!r}")
359
+ if payload.get("week") != week:
360
+ raise SystemExit(f"Raw store week mismatch: expected {week}, found {payload.get('week')!r}")
361
+ if payload.get("source_run_id") != source_run_id:
362
+ raise SystemExit(
363
+ "Raw store source_run_id mismatch: "
364
+ f"expected {source_run_id}, found {payload.get('source_run_id')!r}"
365
+ )
366
+ source_artifact = payload.get("source_artifact")
367
+ artifact_name = source_artifact.get("name") if isinstance(source_artifact, dict) else None
368
+ if (
369
+ not isinstance(source_artifact, dict)
370
+ or not source_artifact.get("id")
371
+ or not (isinstance(artifact_name, str) and artifact_name.strip())
372
+ or not source_artifact.get("head_sha")
373
+ ):
374
+ raise SystemExit("Raw store manifest lacks source artifact provenance.")
375
+ files = payload.get("files")
376
+ if not isinstance(files, list) or not files:
377
+ raise SystemExit("Raw store manifest has no files list.")
378
+
379
+ verified: list[tuple[Path, Path, dict[str, Any]]] = []
380
+ store_directory = manifest.parent.resolve()
381
+ store_directory_relative = manifest.parent.relative_to(root)
382
+ seen_original_paths: set[Path] = set()
383
+ for entry in files:
384
+ if not isinstance(entry, dict):
385
+ raise SystemExit("Raw store file entry is malformed.")
386
+ original_path = entry.get("original_path")
387
+ stored_path = entry.get("stored_path")
388
+ if not isinstance(original_path, str) or not isinstance(stored_path, str):
389
+ raise SystemExit("Raw store file entry lacks original_path or stored_path.")
390
+ if entry.get("week") != week or entry.get("source_run_id") != source_run_id:
391
+ raise SystemExit(f"Raw store file provenance mismatch for {original_path}")
392
+ if entry.get("artifact_id") != source_artifact.get("id") or entry.get(
393
+ "head_sha"
394
+ ) != source_artifact.get("head_sha"):
395
+ raise SystemExit(f"Raw store artifact provenance mismatch for {original_path}")
396
+
397
+ target_relative, target = require_raw_path(root, original_path)
398
+ if target_relative in seen_original_paths:
399
+ raise SystemExit(f"Duplicate raw store original_path: {original_path}")
400
+ seen_original_paths.add(target_relative)
401
+ stored_relative = relpath_under_root(root, stored_path)
402
+ expected_stored_relative = store_directory_relative / "files" / target_relative
403
+ if stored_relative != expected_stored_relative:
404
+ raise SystemExit(f"Raw store stored_path mismatch for {original_path}: {stored_path}")
405
+ stored = root / stored_relative
406
+ try:
407
+ stored.resolve().relative_to(store_directory)
408
+ except ValueError as exc:
409
+ raise SystemExit(
410
+ f"Raw store file must stay under its immutable run directory: {stored_path}"
411
+ ) from exc
412
+ if not stored.exists() or not stored.is_file():
413
+ raise SystemExit(f"Raw store file is missing: {stored_relative.as_posix()}")
414
+ if stored.stat().st_size != entry.get("size_bytes"):
415
+ raise SystemExit(f"Raw store size mismatch for {stored_relative.as_posix()}")
416
+ if sha256_file(stored) != entry.get("sha256"):
417
+ raise SystemExit(f"Raw store checksum mismatch for {stored_relative.as_posix()}")
418
+ verified.append((stored, target, entry))
419
+
420
+ required_raw_path = Path("data") / "raw" / f"{week}.json"
421
+ if required_raw_path not in seen_original_paths:
422
+ raise SystemExit(f"Raw store manifest lacks required payload: {required_raw_path}")
423
+ return payload, verified
424
+
425
+
426
+def restore_raw(args: argparse.Namespace) -> int:
427
+ root = args.root.resolve()
428
+ week = require_week(args.week)
429
+ source_run_id = require_safe_component(args.source_run_id, label="source_run_id")
430
+ store_root = root / relpath_under_root(root, args.store_root.as_posix())
431
+ manifest = store_root / week / source_run_id / "manifest.json"
432
+ _, verified = validate_raw_store_manifest(
433
+ root,
434
+ manifest,
435
+ expected_week=week,
436
+ expected_source_run_id=source_run_id,
437
+ )
438
+
439
+ expected_targets = {target for _, target, _ in verified}
440
+ raw_root = root / "data" / "raw"
441
+ if raw_root.exists():
442
+ for existing in sorted(raw_root.rglob("*.json")):
443
+ if (
444
+ existing.is_file()
445
+ and is_week_raw_json(existing, week)
446
+ and existing not in expected_targets
447
+ ):
448
+ existing.unlink()
449
+
450
+ for stored, target, _ in verified:
451
+ target.parent.mkdir(parents=True, exist_ok=True)
452
+ shutil.copy2(stored, target)
453
+ print(
454
+ "Restored hash-verified raw evidence: "
455
+ f"week={week} source_run_id={source_run_id} files={len(verified)}"
456
+ )
457
+ return 0
458
+
459
+
460
def main(argv: list[str] | None = None) -> int:
461
args = parse_args(argv)
462
if args.command == "backup-existing":
463
return backup_existing(args)
464
if args.command == "restore-backup":
465
return restore_backup(args)
466
+ if args.command == "store-raw":
467
+ return store_raw(args)
468
+ if args.command == "restore-raw":
469
+ return restore_raw(args)
470
raise AssertionError(args.command)
471
472