Implement governed S1 backup recovery loop
Some checks failed
CI Smoke / source-contract (push) Failing after 9s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02994-7685-7940-bf34-3555b8256018
This commit is contained in:
codex 2026-08-23 13:13:13 +02:00
parent 40e295e3bd
commit 295bf43d54
16 changed files with 1623 additions and 95 deletions

534
scripts/s1_backup.py Executable file
View file

@ -0,0 +1,534 @@
#!/usr/bin/env python3
"""Create and inspect atomic, age-encrypted Railiance S1 backup bundles."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import socket
import stat
import subprocess
import sys
import tarfile
import tempfile
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path, PurePosixPath
from typing import Any
from s1_backup_contract import (
FORBIDDEN_PARTS,
BackupContractError,
declaration_sha256,
load_spec,
)
ROOT = Path(__file__).resolve().parents[1]
BUNDLE_RE = re.compile(r"^s1-backup-(\d{8}T\d{6}Z)$")
REVISION_RE = re.compile(r"^[0-9a-f]{7,40}$")
class BackupError(RuntimeError):
"""A backup could not produce one complete, unambiguous bundle."""
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 _utc_now() -> datetime:
return datetime.now(timezone.utc)
def _rfc3339(value: datetime) -> str:
return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _revision(explicit: str | None) -> str:
value = explicit
if value is None:
try:
if subprocess.check_output(
["git", "status", "--porcelain"],
cwd=ROOT,
text=True,
stderr=subprocess.DEVNULL,
).strip():
raise BackupError("source checkout is dirty; commit the exact backup implementation")
value = subprocess.check_output(
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True, stderr=subprocess.DEVNULL
).strip()
except (OSError, subprocess.CalledProcessError) as exc:
raise BackupError("source revision is unavailable; pass --source-revision") from exc
if not REVISION_RE.fullmatch(value):
raise BackupError("source revision must be a 7-40 character lowercase git revision")
return value
def _source_path(source_root: Path, declared: str) -> Path:
relative = PurePosixPath(declared).relative_to("/")
return source_root.joinpath(*relative.parts)
def _walk_members(source_root: Path, declared: str) -> list[Path]:
start = _source_path(source_root, declared)
if not start.exists() and not start.is_symlink():
return []
members = [start]
if start.is_dir() and not start.is_symlink():
for current, directories, files in os.walk(start, followlinks=False):
directories.sort()
files.sort()
base = Path(current)
members.extend(base / name for name in directories)
members.extend(base / name for name in files)
return members
def _member_metadata(path: Path, source_root: Path) -> dict[str, Any]:
relative = path.relative_to(source_root)
lowered = {part.lower() for part in relative.parts}
filename = relative.name.lower()
if lowered & FORBIDDEN_PARTS or filename.endswith((".key", ".pem")) or any(
marker in filename for marker in ("credential", "private_key", "token")
):
raise BackupError(f"runtime member is secret/private-key shaped: /{relative}")
info = path.lstat()
if stat.S_ISREG(info.st_mode):
kind = "file"
elif stat.S_ISDIR(info.st_mode):
kind = "directory"
elif stat.S_ISLNK(info.st_mode):
kind = "symlink"
target = Path(os.readlink(path))
resolved = target if target.is_absolute() else path.parent / target
try:
resolved.resolve(strict=False).relative_to(source_root)
except ValueError as exc:
raise BackupError(f"runtime link escapes fixture/source root: /{relative}") from exc
else:
raise BackupError(f"unsupported filesystem object: /{path.relative_to(source_root)}")
return {
"kind": kind,
"mode": f"{stat.S_IMODE(info.st_mode):04o}",
"path": str(PurePosixPath(*relative.parts)),
"size_bytes": info.st_size if kind == "file" else 0,
}
def _build_os_archive(
*, source_root: Path, declaration: dict[str, Any], destination: Path
) -> list[dict[str, Any]]:
selected: list[Path] = []
missing_required: list[str] = []
for entry in declaration["artifacts"]["os-config"]["paths"]:
members = _walk_members(source_root, entry["path"])
if not members:
if entry["required"]:
missing_required.append(entry["path"])
continue
selected.extend(members)
if missing_required:
raise BackupError("required backup inputs are missing: " + ", ".join(missing_required))
unique = sorted(set(selected), key=lambda path: str(path.relative_to(source_root)))
metadata = [_member_metadata(path, source_root) for path in unique]
by_path = {item["path"]: item for item in metadata}
for entry in declaration["artifacts"]["os-config"]["paths"]:
if not entry["required"]:
continue
relative = str(PurePosixPath(entry["path"]).relative_to("/"))
if by_path[relative]["mode"] != entry["expected_mode"]:
raise BackupError(
f"critical mode mismatch for {entry['path']}: "
f"expected {entry['expected_mode']}, observed {by_path[relative]['mode']}"
)
with tarfile.open(destination, "w:gz", compresslevel=9) as archive:
for path in unique:
archive.add(
path,
arcname=str(PurePosixPath(*path.relative_to(source_root).parts)),
recursive=False,
)
return metadata
def _write_packages(
destination: Path, declaration: dict[str, Any], packages_file: Path | None
) -> None:
if packages_file is not None:
if not packages_file.is_file():
raise BackupError("--packages-file must identify a readable regular file")
shutil.copyfile(packages_file, destination)
return
command = declaration["artifacts"]["packages"]["command"]
try:
with destination.open("wb") as output:
subprocess.run(command, stdout=output, stderr=subprocess.DEVNULL, check=True)
except (OSError, subprocess.CalledProcessError) as exc:
raise BackupError("fixed package-selection command failed") from exc
def _age_binary(explicit: str | None) -> str:
if explicit:
return explicit
for candidate in (shutil.which("age"), "/usr/local/bin/age"):
if candidate and Path(candidate).is_file():
return candidate
raise BackupError("age executable not found")
def _encrypt(age_binary: str, recipients: list[str], source: Path, destination: Path) -> None:
command = [age_binary]
for recipient in recipients:
command.extend(["-r", recipient])
command.extend(["-o", str(destination), str(source)])
try:
subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
except (OSError, subprocess.CalledProcessError) as exc:
raise BackupError("age encryption failed; no bundle was published") from exc
if not destination.is_file() or destination.stat().st_size == 0:
raise BackupError("age encryption produced no usable output")
def _bundle_size(path: Path) -> int:
return sum(item.stat().st_size for item in path.rglob("*") if item.is_file())
def _complete_bundles(output_dir: Path) -> list[Path]:
bundles = []
if not output_dir.is_dir():
return bundles
for path in output_dir.iterdir():
if not path.is_dir() or not BUNDLE_RE.fullmatch(path.name):
continue
receipt = path / "receipt.json"
try:
payload = json.loads(receipt.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if payload.get("event_type") == "backup" and payload.get("status") == "pass":
bundles.append(path)
return sorted(bundles, key=lambda item: item.name, reverse=True)
def _retention_candidates(output_dir: Path, keep: int, max_bytes: int) -> list[Path]:
bundles = _complete_bundles(output_dir)
retained = list(bundles)
candidates: list[Path] = []
while len(retained) > keep:
candidates.append(retained.pop())
while sum(_bundle_size(path) for path in retained) > max_bytes and len(retained) > 2:
candidates.append(retained.pop())
return candidates
def _prune_approval(output_dir: Path, candidates: list[Path]) -> str:
material = "\n".join(
f"{path.name}:{_sha256(path / 'receipt.json')}" for path in sorted(candidates)
)
digest = hashlib.sha256(material.encode()).hexdigest()[:16].upper()
return f"PRUNE-S1-BACKUPS-{digest}"
def plan_prune(spec_path: Path, output_dir: Path | None) -> dict[str, Any]:
declaration = load_spec(spec_path)
root = Path(output_dir or declaration["backup_root"]).resolve()
if root != Path(declaration["backup_root"]).resolve() and output_dir is None:
raise BackupError("retention root does not match the declaration")
retention = declaration["retention"]
candidates = _retention_candidates(
root,
retention["keep_complete_bundles"],
retention["max_total_bytes"],
)
return {
"approval": _prune_approval(root, candidates) if candidates else None,
"backup_root": str(root),
"candidates": [path.name for path in candidates],
"status": "approval-required" if candidates else "nothing-to-prune",
}
def apply_prune(spec_path: Path, output_dir: Path | None, approval: str) -> dict[str, Any]:
plan = plan_prune(spec_path, output_dir)
if not plan["candidates"]:
return plan
if approval != plan["approval"]:
raise BackupError("prune approval does not match the current exact candidate set")
root = Path(plan["backup_root"])
quarantine = root / f".prune-{uuid.uuid4()}"
quarantine.mkdir(mode=0o700)
moved: list[Path] = []
try:
for name in plan["candidates"]:
source = root / name
if source.parent != root or not BUNDLE_RE.fullmatch(source.name):
raise BackupError("prune candidate escaped the declared backup root")
target = quarantine / source.name
os.replace(source, target)
moved.append(target)
directory_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
except Exception:
for target in reversed(moved):
if target.exists():
os.replace(target, root / target.name)
shutil.rmtree(quarantine, ignore_errors=True)
raise
shutil.rmtree(quarantine)
return {
"backup_root": str(root),
"deleted": plan["candidates"],
"status": "pruned",
}
def _retention_decision(output_dir: Path, keep: int, max_bytes: int, new_size: int) -> dict[str, Any]:
projected = [(path.name, _bundle_size(path)) for path in _complete_bundles(output_dir)]
projected.insert(0, ("new-bundle", new_size))
candidates: list[str] = []
total = sum(size for _, size in projected)
retained = list(projected)
while len(retained) > keep:
name, size = retained.pop()
candidates.append(name)
total -= size
while total > max_bytes and len(retained) > 2:
name, size = retained.pop()
candidates.append(name)
total -= size
return {
"keep_complete_bundles": keep,
"max_total_bytes": max_bytes,
"projected_total_bytes": total,
"prune_candidates": sorted(name for name in candidates if name != "new-bundle"),
"pruning_applied": False,
}
def create_bundle(
*,
spec_path: Path,
source_root: Path,
output_dir: Path | None,
packages_file: Path | None,
age_binary: str | None,
source_revision: str | None,
timestamp: str | None,
hostname: str | None,
) -> Path:
declaration = load_spec(spec_path)
source_root = source_root.resolve()
production = source_root == Path("/")
if production and os.geteuid() != 0:
raise BackupError("production backup requires root before creating the destination")
if packages_file is not None and production:
raise BackupError("--packages-file is allowed only with a fixture --source-root")
destination_root = Path(output_dir or declaration["backup_root"])
if not destination_root.is_absolute():
raise BackupError("output directory must be absolute")
if destination_root.resolve() in {Path("/"), Path("/etc"), Path("/home"), Path("/root")}:
raise BackupError("output directory must be bounded")
observed = _utc_now()
stamp = timestamp or observed.strftime("%Y%m%dT%H%M%SZ")
if not re.fullmatch(r"\d{8}T\d{6}Z", stamp):
raise BackupError("timestamp must be YYYYMMDDTHHMMSSZ")
bundle_name = f"s1-backup-{stamp}"
final = destination_root / bundle_name
if final.exists():
raise BackupError(f"refusing to overwrite existing bundle {bundle_name}")
destination_root.mkdir(parents=True, exist_ok=True, mode=0o700)
stage = Path(tempfile.mkdtemp(prefix=f".{bundle_name}.", dir=destination_root))
os.chmod(stage, 0o700)
try:
plain_os = stage / ".os-config.tar.gz"
plain_packages = stage / ".packages.txt"
members = _build_os_archive(
source_root=source_root, declaration=declaration, destination=plain_os
)
_write_packages(plain_packages, declaration, packages_file)
executable = _age_binary(age_binary)
encrypted = {
"os-config": stage / "os-config.tar.gz.age",
"packages": stage / "packages.txt.age",
}
_encrypt(executable, declaration["recipients"], plain_os, encrypted["os-config"])
_encrypt(executable, declaration["recipients"], plain_packages, encrypted["packages"])
plain_os.unlink()
plain_packages.unlink()
revision = _revision(source_revision)
declaration_digest = declaration_sha256(spec_path)
artifacts = [
{
"artifact_class": artifact_class,
"encrypted_sha256": _sha256(path),
"filename": path.name,
"size_bytes": path.stat().st_size,
}
for artifact_class, path in encrypted.items()
]
manifest = {
"schema_version": "1.0",
"bundle_id": bundle_name,
"created_at": _rfc3339(observed),
"host": hostname or socket.gethostname(),
"source_revision": revision,
"declaration_sha256": declaration_digest,
"recipient_sha256": [
hashlib.sha256(value.encode()).hexdigest() for value in declaration["recipients"]
],
"artifacts": artifacts,
"os_config_members": members,
"package_source": "fixture-file" if packages_file else "dpkg --get-selections",
}
manifest_path = stage / "manifest.json"
manifest_path.write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
retention = declaration["retention"]
receipt = {
"schema_version": "1.0",
"receipt_id": str(uuid.uuid4()),
"event_type": "backup",
"status": "pass",
"created_at": _rfc3339(observed),
"bundle_id": bundle_name,
"host": manifest["host"],
"source_revision": revision,
"declaration_sha256": declaration_digest,
"manifest_sha256": _sha256(manifest_path),
"artifacts": artifacts,
"retention": _retention_decision(
destination_root,
retention["keep_complete_bundles"],
retention["max_total_bytes"],
_bundle_size(stage),
),
}
(stage / "receipt.json").write_text(
json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
for path in stage.iterdir():
if path.is_file():
with path.open("rb") as handle:
os.fsync(handle.fileno())
os.replace(stage, final)
directory_fd = os.open(destination_root, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
return final
except Exception:
shutil.rmtree(stage, ignore_errors=True)
raise
def inspect_status(spec_path: Path, output_dir: Path | None) -> dict[str, Any]:
declaration = load_spec(spec_path)
root = Path(output_dir or declaration["backup_root"])
bundles = _complete_bundles(root)
if not bundles:
raise BackupError("no complete S1 backup bundle exists")
newest = bundles[0]
# Import lazily to keep the create path independent while making status
# prove the newest evidence, rather than trusting a pass-shaped JSON file.
from s1_restore import RestoreError, validate_bundle
try:
receipt = validate_bundle(newest, spec_path)["receipt"]
created = datetime.fromisoformat(receipt["created_at"].replace("Z", "+00:00"))
except (RestoreError, KeyError, TypeError, ValueError) as exc:
raise BackupError(f"newest backup evidence is invalid: {exc}") from exc
fresh_until = created + timedelta(hours=declaration["retention"]["freshness_hours"])
total = sum(_bundle_size(bundle) for bundle in bundles)
result = {
"bundle": newest.name,
"bundle_count": len(bundles),
"created_at": receipt["created_at"],
"fresh_until": _rfc3339(fresh_until),
"status": "pass",
"total_bytes": total,
}
if _utc_now() >= fresh_until:
result["status"] = "stale"
if len(bundles) > declaration["retention"]["keep_complete_bundles"]:
result["status"] = "over-retention"
if total > declaration["retention"]["max_total_bytes"]:
result["status"] = "over-budget"
return result
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
check = subparsers.add_parser("check", help="validate source-only backup inputs")
check.add_argument("--spec", type=Path, default=ROOT / "spec" / "s1-backup.yaml")
create = subparsers.add_parser("create", help="create one complete encrypted bundle")
create.add_argument("--spec", type=Path, default=ROOT / "spec" / "s1-backup.yaml")
create.add_argument("--source-root", type=Path, default=Path("/"))
create.add_argument("--output-dir", type=Path)
create.add_argument("--packages-file", type=Path)
create.add_argument("--age-bin")
create.add_argument("--source-revision")
create.add_argument("--timestamp")
create.add_argument("--hostname")
status_parser = subparsers.add_parser("status", help="fail if backup evidence is absent or stale")
status_parser.add_argument("--spec", type=Path, default=ROOT / "spec" / "s1-backup.yaml")
status_parser.add_argument("--output-dir", type=Path)
prune_plan = subparsers.add_parser(
"prune-plan", help="print the exact retained bundles requiring deletion approval"
)
prune_plan.add_argument("--spec", type=Path, default=ROOT / "spec" / "s1-backup.yaml")
prune_plan.add_argument("--output-dir", type=Path)
prune = subparsers.add_parser("prune", help="apply one exact, freshly rendered prune plan")
prune.add_argument("--spec", type=Path, default=ROOT / "spec" / "s1-backup.yaml")
prune.add_argument("--output-dir", type=Path)
prune.add_argument("--approval", required=True)
args = parser.parse_args()
try:
if args.command == "check":
declaration = load_spec(args.spec)
result = {"ok": True, "artifacts": sorted(declaration["artifacts"])}
elif args.command == "create":
bundle = create_bundle(
spec_path=args.spec,
source_root=args.source_root,
output_dir=args.output_dir,
packages_file=args.packages_file,
age_binary=args.age_bin,
source_revision=args.source_revision,
timestamp=args.timestamp,
hostname=args.hostname,
)
result = {"bundle": str(bundle), "status": "pass"}
elif args.command == "status":
result = inspect_status(args.spec, args.output_dir)
elif args.command == "prune-plan":
result = plan_prune(args.spec, args.output_dir)
else:
result = apply_prune(args.spec, args.output_dir, args.approval)
print(json.dumps(result, sort_keys=True))
return 0 if result.get("status", "pass") == "pass" else 1
except (BackupContractError, BackupError, OSError, tarfile.TarError) as exc:
print(f"S1 backup failed closed: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

208
scripts/s1_backup_contract.py Executable file
View file

@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Validate the declared S1 backup membership and public-recipient policy."""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path, PurePosixPath
from typing import Any
import yaml
SCHEMA_VERSION = "1.0"
AGE_RECIPIENT_RE = re.compile(r"^age1[023456789acdefghjklmnpqrstuvwxyz]{20,}$")
REQUIRED_EXCLUSIONS = {
"/etc/ssl/private",
"/home",
"/root",
"/var/lib/kubelet",
"/var/lib/rancher",
}
FORBIDDEN_PARTS = {"id_rsa", "id_ed25519", "keys.txt", "private", "secrets"}
class BackupContractError(ValueError):
"""The backup declaration is ambiguous, unsafe, or inconsistent."""
def _absolute_path(value: Any, label: str) -> str:
if not isinstance(value, str) or not value.startswith("/"):
raise BackupContractError(f"{label} must be an absolute path")
path = PurePosixPath(value)
if ".." in path.parts or str(path) != value.rstrip("/"):
raise BackupContractError(f"{label} must be normalized without traversal")
return str(path)
def _sops_recipients(path: Path) -> set[str]:
try:
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as exc:
raise BackupContractError(f"cannot read recipient policy {path}: {exc}") from exc
recipients: set[str] = set()
for rule in payload.get("creation_rules", []) if isinstance(payload, dict) else []:
for group in rule.get("key_groups", []) if isinstance(rule, dict) else []:
age = group.get("age", []) if isinstance(group, dict) else []
if isinstance(age, list):
recipients.update(item.strip() for item in age if isinstance(item, str))
if not recipients:
raise BackupContractError("recipient policy contains no age recipients")
return recipients
def validate_spec(payload: Any, *, policy_path: Path) -> dict[str, Any]:
if not isinstance(payload, dict):
raise BackupContractError("backup declaration must be a YAML object")
errors: list[str] = []
if str(payload.get("schema_version")) != SCHEMA_VERSION:
errors.append(f"schema_version must be {SCHEMA_VERSION}")
try:
backup_root = _absolute_path(payload.get("backup_root"), "backup_root")
except BackupContractError as exc:
errors.append(str(exc))
backup_root = ""
if backup_root in {"", "/", "/etc", "/home", "/root", "/var"}:
errors.append("backup_root must be a dedicated bounded directory")
recipients = payload.get("recipients")
if not isinstance(recipients, list) or not recipients or not all(
isinstance(item, str) and AGE_RECIPIENT_RE.fullmatch(item) for item in recipients
):
errors.append("recipients must be a non-empty list of age public recipients")
recipients = []
elif len(set(recipients)) != len(recipients):
errors.append("recipients contains duplicates")
try:
policy_recipients = _sops_recipients(policy_path)
if set(recipients) != policy_recipients:
errors.append("backup recipients must exactly match the SOPS public-recipient policy")
except BackupContractError as exc:
errors.append(str(exc))
retention = payload.get("retention")
if not isinstance(retention, dict):
errors.append("retention must be an object")
else:
for key, low, high in (
("keep_complete_bundles", 2, 90),
("max_total_bytes", 1048576, 10737418240),
("freshness_hours", 1, 168),
):
value = retention.get(key)
if not isinstance(value, int) or isinstance(value, bool) or not low <= value <= high:
errors.append(f"retention.{key} must be an integer from {low} to {high}")
exclusions = payload.get("excluded_prefixes")
normalized_exclusions: list[str] = []
if not isinstance(exclusions, list):
errors.append("excluded_prefixes must be a list")
else:
for index, value in enumerate(exclusions):
try:
normalized_exclusions.append(
_absolute_path(value, f"excluded_prefixes[{index}]")
)
except BackupContractError as exc:
errors.append(str(exc))
missing = sorted(REQUIRED_EXCLUSIONS - set(normalized_exclusions))
if missing:
errors.append(f"excluded_prefixes omits governed exclusions {', '.join(missing)}")
artifacts = payload.get("artifacts")
if not isinstance(artifacts, dict) or set(artifacts) != {"os-config", "packages"}:
errors.append("artifacts must contain exactly os-config and packages")
artifacts = {}
os_config = artifacts.get("os-config", {})
paths = os_config.get("paths") if isinstance(os_config, dict) else None
declared: list[str] = []
if not isinstance(paths, list) or not paths:
errors.append("artifacts.os-config.paths must be a non-empty list")
else:
for index, item in enumerate(paths):
label = f"artifacts.os-config.paths[{index}]"
if not isinstance(item, dict) or not isinstance(item.get("required"), bool):
errors.append(f"{label} must contain path and boolean required")
continue
expected_mode = item.get("expected_mode")
if item["required"] and not (
isinstance(expected_mode, str) and re.fullmatch(r"0[0-7]{3}", expected_mode)
):
errors.append(f"{label} requires expected_mode as four octal digits")
if not item["required"] and expected_mode is not None:
errors.append(f"{label}.expected_mode is allowed only for required paths")
try:
path = _absolute_path(item.get("path"), f"{label}.path")
except BackupContractError as exc:
errors.append(str(exc))
continue
declared.append(path)
lowered = {part.lower() for part in PurePosixPath(path).parts}
if lowered & FORBIDDEN_PARTS:
errors.append(f"{label}.path selects secret/private-key shaped content")
if any(path == prefix or path.startswith(prefix + "/") for prefix in normalized_exclusions):
errors.append(f"{label}.path falls under excluded prefix")
if len(set(declared)) != len(declared):
errors.append("artifacts.os-config.paths contains duplicates")
for left in declared:
for right in declared:
if left != right and right.startswith(left + "/"):
errors.append(f"declared paths overlap: {left} contains {right}")
if isinstance(os_config, dict) and os_config.get("format") != "tar.gz":
errors.append("artifacts.os-config.format must be tar.gz")
packages = artifacts.get("packages", {})
if not isinstance(packages, dict) or packages.get("format") != "dpkg-selections":
errors.append("artifacts.packages.format must be dpkg-selections")
elif packages.get("command") != ["dpkg", "--get-selections"]:
errors.append("artifacts.packages.command must be the fixed dpkg selection argv")
if errors:
raise BackupContractError("backup contract failed:\n- " + "\n- ".join(errors))
return payload
def load_spec(path: Path) -> dict[str, Any]:
try:
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as exc:
raise BackupContractError(f"cannot read {path}: {exc}") from exc
policy_value = payload.get("recipient_policy") if isinstance(payload, dict) else None
if not isinstance(policy_value, str) or not policy_value:
raise BackupContractError("recipient_policy must name a repository-relative file")
policy_relative = PurePosixPath(policy_value)
if policy_relative.is_absolute() or ".." in policy_relative.parts:
raise BackupContractError("recipient_policy must be a normalized repository-relative path")
root = path.resolve().parent.parent
return validate_spec(payload, policy_path=root / policy_relative)
def declaration_sha256(path: Path) -> str:
import hashlib
return hashlib.sha256(path.read_bytes()).hexdigest()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("spec", nargs="?", type=Path, default=Path("spec/s1-backup.yaml"))
args = parser.parse_args()
payload = load_spec(args.spec)
print(
json.dumps(
{
"artifacts": sorted(payload["artifacts"]),
"declaration_sha256": declaration_sha256(args.spec),
"ok": True,
"path_count": len(payload["artifacts"]["os-config"]["paths"]),
"recipients": len(payload["recipients"]),
},
sort_keys=True,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

284
scripts/s1_restore.py Executable file
View 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())