Implement governed S1 backup recovery loop
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02994-7685-7940-bf34-3555b8256018
This commit is contained in:
parent
40e295e3bd
commit
295bf43d54
16 changed files with 1623 additions and 95 deletions
284
scripts/s1_restore.py
Executable file
284
scripts/s1_restore.py
Executable file
|
|
@ -0,0 +1,284 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Inspect or extract an S1 backup into an isolated staging directory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from s1_backup import BackupError, _age_binary, _sha256
|
||||
from s1_backup_contract import BackupContractError, declaration_sha256, load_spec
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MANIFEST_KEYS = {
|
||||
"artifacts",
|
||||
"bundle_id",
|
||||
"created_at",
|
||||
"declaration_sha256",
|
||||
"host",
|
||||
"os_config_members",
|
||||
"package_source",
|
||||
"recipient_sha256",
|
||||
"schema_version",
|
||||
"source_revision",
|
||||
}
|
||||
BACKUP_RECEIPT_KEYS = {
|
||||
"artifacts",
|
||||
"bundle_id",
|
||||
"created_at",
|
||||
"declaration_sha256",
|
||||
"event_type",
|
||||
"host",
|
||||
"manifest_sha256",
|
||||
"receipt_id",
|
||||
"retention",
|
||||
"schema_version",
|
||||
"source_revision",
|
||||
"status",
|
||||
}
|
||||
|
||||
|
||||
class RestoreError(RuntimeError):
|
||||
"""An artifact or restore destination is unsafe or inconsistent."""
|
||||
|
||||
|
||||
def _json(path: Path, label: str) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise RestoreError(f"cannot read {label}") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise RestoreError(f"{label} must be a JSON object")
|
||||
return payload
|
||||
|
||||
|
||||
def validate_bundle(bundle: Path, spec_path: Path) -> dict[str, Any]:
|
||||
bundle = bundle.resolve()
|
||||
if not bundle.is_dir() or not bundle.name.startswith("s1-backup-"):
|
||||
raise RestoreError("bundle must be one complete s1-backup-* directory")
|
||||
declaration = load_spec(spec_path)
|
||||
manifest_path = bundle / "manifest.json"
|
||||
receipt_path = bundle / "receipt.json"
|
||||
manifest = _json(manifest_path, "manifest")
|
||||
receipt = _json(receipt_path, "receipt")
|
||||
if set(manifest) != MANIFEST_KEYS:
|
||||
raise RestoreError("manifest fields do not match the metadata-only contract")
|
||||
if set(receipt) != BACKUP_RECEIPT_KEYS:
|
||||
raise RestoreError("receipt fields do not match the metadata-only contract")
|
||||
if manifest.get("schema_version") != "1.0" or receipt.get("schema_version") != "1.0":
|
||||
raise RestoreError("unsupported backup evidence schema version")
|
||||
if receipt.get("event_type") != "backup" or receipt.get("status") != "pass":
|
||||
raise RestoreError("bundle has no passing backup receipt")
|
||||
if manifest.get("bundle_id") != bundle.name or receipt.get("bundle_id") != bundle.name:
|
||||
raise RestoreError("bundle identity does not match its directory")
|
||||
for shared in (
|
||||
"artifacts",
|
||||
"created_at",
|
||||
"declaration_sha256",
|
||||
"host",
|
||||
"source_revision",
|
||||
):
|
||||
if receipt.get(shared) != manifest.get(shared):
|
||||
raise RestoreError(f"receipt and manifest {shared} differ")
|
||||
if receipt.get("manifest_sha256") != _sha256(manifest_path):
|
||||
raise RestoreError("manifest digest does not match the receipt")
|
||||
expected_declaration = declaration_sha256(spec_path)
|
||||
if manifest.get("declaration_sha256") != expected_declaration:
|
||||
raise RestoreError("bundle was not produced from the supplied declaration")
|
||||
if receipt.get("declaration_sha256") != expected_declaration:
|
||||
raise RestoreError("receipt declaration digest is inconsistent")
|
||||
artifacts = manifest.get("artifacts")
|
||||
if not isinstance(artifacts, list) or {
|
||||
item.get("artifact_class") for item in artifacts if isinstance(item, dict)
|
||||
} != {"os-config", "packages"}:
|
||||
raise RestoreError("manifest must describe exactly two artifact classes")
|
||||
for item in artifacts:
|
||||
filename = item.get("filename")
|
||||
if not isinstance(filename, str) or Path(filename).name != filename:
|
||||
raise RestoreError("artifact filename must be a basename")
|
||||
artifact = bundle / filename
|
||||
if not artifact.is_file():
|
||||
raise RestoreError(f"artifact is missing: {filename}")
|
||||
if item.get("encrypted_sha256") != _sha256(artifact):
|
||||
raise RestoreError(f"artifact digest mismatch: {filename}")
|
||||
if item.get("size_bytes") != artifact.stat().st_size:
|
||||
raise RestoreError(f"artifact size mismatch: {filename}")
|
||||
members = manifest.get("os_config_members")
|
||||
if not isinstance(members, list) or not members:
|
||||
raise RestoreError("manifest contains no OS configuration membership")
|
||||
member_by_path = {
|
||||
item.get("path"): item for item in members if isinstance(item, dict)
|
||||
}
|
||||
for entry in declaration["artifacts"]["os-config"]["paths"]:
|
||||
if not entry["required"]:
|
||||
continue
|
||||
relative = str(PurePosixPath(entry["path"]).relative_to("/"))
|
||||
member = member_by_path.get(relative)
|
||||
if not member or member.get("mode") != entry["expected_mode"]:
|
||||
raise RestoreError(f"required member or declared critical mode differs: {entry['path']}")
|
||||
return {"bundle": bundle, "declaration": declaration, "manifest": manifest, "receipt": receipt}
|
||||
|
||||
|
||||
def _safe_member(member: tarfile.TarInfo) -> dict[str, Any]:
|
||||
path = PurePosixPath(member.name)
|
||||
if path.is_absolute() or ".." in path.parts or str(path) != member.name.rstrip("/"):
|
||||
raise RestoreError(f"unsafe archive member path: {member.name}")
|
||||
if member.isdev() or member.isfifo():
|
||||
raise RestoreError(f"unsupported archive member type: {member.name}")
|
||||
if not (member.isfile() or member.isdir() or member.issym() or member.islnk()):
|
||||
raise RestoreError(f"unsupported archive member type: {member.name}")
|
||||
if member.issym() or member.islnk():
|
||||
target = PurePosixPath(member.linkname)
|
||||
combined = target if target.is_absolute() else path.parent / target
|
||||
if target.is_absolute() or ".." in combined.parts:
|
||||
raise RestoreError(f"archive link escapes staging root: {member.name}")
|
||||
kind = "file" if member.isfile() else "directory" if member.isdir() else "symlink"
|
||||
return {
|
||||
"kind": kind,
|
||||
"mode": f"{member.mode:04o}",
|
||||
"path": str(path),
|
||||
"size_bytes": member.size if member.isfile() else 0,
|
||||
}
|
||||
|
||||
|
||||
def _decrypt(age_binary: str, identity: Path, source: Path, destination: Path) -> None:
|
||||
try:
|
||||
subprocess.run(
|
||||
[age_binary, "-d", "-i", str(identity), "-o", str(destination), str(source)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
except (OSError, subprocess.CalledProcessError) as exc:
|
||||
raise RestoreError("artifact decryption failed with output suppressed") from exc
|
||||
|
||||
|
||||
def _safe_destination(destination: Path) -> Path:
|
||||
resolved = destination.resolve()
|
||||
if not resolved.is_absolute() or resolved in {
|
||||
Path("/"),
|
||||
Path("/etc"),
|
||||
Path("/home"),
|
||||
Path("/root"),
|
||||
Path("/var"),
|
||||
}:
|
||||
raise RestoreError("restore destination must be a bounded isolated directory")
|
||||
try:
|
||||
resolved.relative_to(ROOT)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise RestoreError("restore destination must not be inside the repository")
|
||||
if not resolved.parent.is_dir():
|
||||
raise RestoreError("restore destination parent must already exist")
|
||||
if resolved.exists() and (not resolved.is_dir() or any(resolved.iterdir())):
|
||||
raise RestoreError("restore destination must be absent or an empty directory")
|
||||
return resolved
|
||||
|
||||
|
||||
def extract_bundle(
|
||||
*, bundle: Path, spec_path: Path, destination: Path, identity: Path, age_binary: str | None
|
||||
) -> Path:
|
||||
validated = validate_bundle(bundle, spec_path)
|
||||
if not identity.is_file():
|
||||
raise RestoreError("age identity must be supplied as a readable file")
|
||||
final = _safe_destination(destination)
|
||||
stage = Path(tempfile.mkdtemp(prefix=f".{final.name}.", dir=final.parent))
|
||||
os.chmod(stage, 0o700)
|
||||
try:
|
||||
artifacts = {
|
||||
item["artifact_class"]: validated["bundle"] / item["filename"]
|
||||
for item in validated["manifest"]["artifacts"]
|
||||
}
|
||||
executable = _age_binary(age_binary)
|
||||
plain_archive = stage / ".os-config.tar.gz"
|
||||
_decrypt(executable, identity, artifacts["os-config"], plain_archive)
|
||||
with tarfile.open(plain_archive, "r:gz") as archive:
|
||||
observed = [_safe_member(member) for member in archive.getmembers()]
|
||||
expected = validated["manifest"]["os_config_members"]
|
||||
if observed != expected:
|
||||
raise RestoreError("decrypted archive membership or modes differ from manifest")
|
||||
archive.extractall(path=stage, filter="data")
|
||||
plain_archive.unlink()
|
||||
packages = stage / "packages.txt"
|
||||
_decrypt(executable, identity, artifacts["packages"], packages)
|
||||
os.chmod(packages, 0o600)
|
||||
if packages.stat().st_size == 0 or b"\x00" in packages.read_bytes():
|
||||
raise RestoreError("package-selection artifact is empty or not text")
|
||||
|
||||
receipt = {
|
||||
"schema_version": "1.0",
|
||||
"receipt_id": str(uuid.uuid4()),
|
||||
"event_type": "restore",
|
||||
"status": "pass",
|
||||
"created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"bundle_id": validated["manifest"]["bundle_id"],
|
||||
"source_revision": validated["manifest"]["source_revision"],
|
||||
"declaration_sha256": validated["manifest"]["declaration_sha256"],
|
||||
"manifest_sha256": _sha256(validated["bundle"] / "manifest.json"),
|
||||
"member_count": len(validated["manifest"]["os_config_members"]),
|
||||
"isolated": True,
|
||||
}
|
||||
(stage / "restore-receipt.json").write_text(
|
||||
json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
||||
)
|
||||
if final.exists():
|
||||
final.rmdir()
|
||||
os.replace(stage, final)
|
||||
return final
|
||||
except Exception:
|
||||
shutil.rmtree(stage, ignore_errors=True)
|
||||
raise
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("bundle", type=Path)
|
||||
parser.add_argument("--spec", type=Path, default=ROOT / "spec" / "s1-backup.yaml")
|
||||
parser.add_argument("--extract-to", type=Path)
|
||||
parser.add_argument("--identity", type=Path)
|
||||
parser.add_argument("--age-bin")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.extract_to is None:
|
||||
if args.identity is not None:
|
||||
parser.error("--identity is used only with --extract-to")
|
||||
validated = validate_bundle(args.bundle, args.spec)
|
||||
result = {
|
||||
"artifacts": len(validated["manifest"]["artifacts"]),
|
||||
"bundle": validated["manifest"]["bundle_id"],
|
||||
"member_count": len(validated["manifest"]["os_config_members"]),
|
||||
"status": "pass",
|
||||
}
|
||||
else:
|
||||
if args.identity is None:
|
||||
parser.error("--extract-to requires --identity")
|
||||
restored = extract_bundle(
|
||||
bundle=args.bundle,
|
||||
spec_path=args.spec,
|
||||
destination=args.extract_to,
|
||||
identity=args.identity,
|
||||
age_binary=args.age_bin,
|
||||
)
|
||||
result = {"restored_to": str(restored), "status": "pass"}
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
except (BackupContractError, BackupError, RestoreError, OSError, tarfile.TarError) as exc:
|
||||
print(f"S1 restore failed closed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue