Files
geointel/scripts/release_backup_guard.py
T
Codex 7b96037853
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
Operationalize RC10 data retention
2026-07-18 06:58:10 +02:00

127 lines
4.6 KiB
Python

#!/usr/bin/env python3
"""Verification guard shared by destructive GeoIntel operator commands."""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
@dataclass(frozen=True)
class VerifiedBackup:
backup_dir: Path
release_id: str
created_at: datetime
age_hours: float
git_commit: str
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _created_at(value: object) -> datetime:
if not isinstance(value, str):
raise RuntimeError("Backup manifest does not contain created_at")
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def verify_current_backup(
backup_dir: str | Path,
*,
max_age_hours: float = 24.0,
now: datetime | None = None,
) -> VerifiedBackup:
"""Verify checksums and release metadata without mutating the backup."""
if max_age_hours <= 0:
raise ValueError("max_age_hours must be greater than zero")
root = Path(backup_dir).expanduser().resolve()
if not root.is_dir():
raise RuntimeError(f"Backup directory does not exist: {root}")
required = {
"manifest.json",
"CHECKSUMS.sha256",
"database.dump",
"database.list",
"database-metadata.tsv",
"table-counts.tsv",
"storage-manifest.tsv",
}
missing = sorted(name for name in required if not (root / name).is_file())
if missing:
raise RuntimeError(f"Backup is incomplete; missing: {', '.join(missing)}")
checksum_lines = (root / "CHECKSUMS.sha256").read_text(encoding="utf-8").splitlines()
checked: set[str] = set()
for line in checksum_lines:
if not line.strip():
continue
try:
expected, name = line.split(maxsplit=1)
except ValueError as exc:
raise RuntimeError("Backup checksum file has an invalid line") from exc
name = name.lstrip("*")
if "/" in name or "\\" in name or name in {".", ".."}:
raise RuntimeError(f"Backup checksum contains an unsafe path: {name}")
target = root / name
if not target.is_file():
raise RuntimeError(f"Backup checksum target is missing: {name}")
if _sha256(target) != expected.lower():
raise RuntimeError(f"Backup checksum mismatch: {name}")
checked.add(name)
unchecked = sorted((required - {"CHECKSUMS.sha256"}) - checked)
if unchecked:
raise RuntimeError(f"Backup checksum coverage is incomplete: {', '.join(unchecked)}")
manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
if manifest.get("schema_version") != 1 or manifest.get("read_only_source") is not True:
raise RuntimeError("Backup manifest schema or read-only marker is invalid")
if manifest.get("database_password_secure") is not True:
raise RuntimeError("Backup was made from an insecure database configuration")
if manifest.get("inventory_mode") != "sha256" or manifest.get("storage_inventory_requested") is not True:
raise RuntimeError("Destructive maintenance requires a SHA-256 storage inventory backup")
created = _created_at(manifest.get("created_at"))
current = now or datetime.now(timezone.utc)
if current.tzinfo is None:
current = current.replace(tzinfo=timezone.utc)
age_hours = (current.astimezone(timezone.utc) - created).total_seconds() / 3600
if age_hours < -0.1:
raise RuntimeError("Backup timestamp is in the future")
if age_hours > max_age_hours:
raise RuntimeError(
f"Backup is {age_hours:.1f} hours old; maximum allowed age is {max_age_hours:.1f} hours"
)
release_id = manifest.get("release_id")
git_commit = manifest.get("git_commit")
if not isinstance(release_id, str) or not release_id:
raise RuntimeError("Backup release id is missing")
if not isinstance(git_commit, str) or len(git_commit) < 7:
raise RuntimeError("Backup Git commit is missing")
return VerifiedBackup(
backup_dir=root,
release_id=release_id,
created_at=created,
age_hours=age_hours,
git_commit=git_commit,
)
def require_confirmation(actual: str | None, expected: str) -> None:
if actual != expected:
raise RuntimeError(f"Refusing destructive maintenance; pass --confirm {expected}")