244 lines
9.5 KiB
Python
244 lines
9.5 KiB
Python
"""Hash-verified, clean-room backup and restore primitives.
|
|
|
|
Archives contain only an explicit allowlist of non-secret inputs. Database
|
|
dump commands use mounted service configuration and never accept a DSN on the
|
|
command line, keeping credentials out of process listings and evidence.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tarfile
|
|
from collections.abc import Iterable, Mapping
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from packages.hub_observability import sanitize_fields
|
|
|
|
|
|
class BackupError(ValueError):
|
|
"""Raised for unsafe, corrupt, or incomplete backup operations."""
|
|
|
|
|
|
_SECRET_NAME = {".env", ".env.example", "secrets", "secret", "password", "credentials"}
|
|
|
|
|
|
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 _safe_relative(root: Path, candidate: Path) -> Path:
|
|
try:
|
|
return candidate.resolve().relative_to(root.resolve())
|
|
except ValueError as exc:
|
|
raise BackupError("backup input must remain below its registered root") from exc
|
|
|
|
|
|
def _is_secret_path(relative: Path) -> bool:
|
|
return any(
|
|
any(
|
|
term in part.lower()
|
|
for term in ("secret", "password", "credential", "token", "private_key")
|
|
)
|
|
or part.lower() in _SECRET_NAME
|
|
for part in relative.parts
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BackupPolicy:
|
|
retention_days: int = 30
|
|
max_artifacts: int = 14
|
|
encryption_required_before_export: bool = True
|
|
|
|
def __post_init__(self) -> None:
|
|
if not 1 <= self.retention_days <= 3650:
|
|
raise ValueError("retention_days must be between 1 and 3650")
|
|
if not 1 <= self.max_artifacts <= 1000:
|
|
raise ValueError("max_artifacts must be between 1 and 1000")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BackupArtifact:
|
|
archive: Path
|
|
manifest: Path
|
|
sha256: str
|
|
bytes: int
|
|
entries: tuple[str, ...]
|
|
created_at: str
|
|
|
|
|
|
class BackupManager:
|
|
def __init__(self, backup_root: Path, *, policy: BackupPolicy | None = None) -> None:
|
|
self.backup_root = backup_root.resolve()
|
|
self.policy = policy or BackupPolicy()
|
|
|
|
def create_archive(
|
|
self,
|
|
source_root: Path,
|
|
*,
|
|
name: str,
|
|
include: Iterable[Path],
|
|
metadata: Mapping[str, Any] | None = None,
|
|
) -> BackupArtifact:
|
|
if not name or Path(name).name != name or name in {".", ".."}:
|
|
raise BackupError("backup name must be a single safe path component")
|
|
root = source_root.resolve()
|
|
self.backup_root.mkdir(parents=True, exist_ok=True)
|
|
archive = self.backup_root / f"{name}.tar.gz"
|
|
manifest_path = self.backup_root / f"{name}.manifest.json"
|
|
entries: list[str] = []
|
|
with tarfile.open(archive, "w:gz") as bundle:
|
|
for raw in include:
|
|
candidate = (root / raw).resolve() if not raw.is_absolute() else raw.resolve()
|
|
relative = _safe_relative(root, candidate)
|
|
if _is_secret_path(relative):
|
|
raise BackupError(
|
|
f"secret-bearing path is not eligible for ordinary backup: {relative}"
|
|
)
|
|
if not candidate.exists():
|
|
raise BackupError(f"backup input does not exist: {relative}")
|
|
if candidate.is_symlink() or (
|
|
candidate.is_dir() and any(item.is_symlink() for item in candidate.rglob("*"))
|
|
):
|
|
raise BackupError("symlink backup inputs are not allowed")
|
|
bundle.add(candidate, arcname=relative.as_posix(), recursive=True)
|
|
entries.append(relative.as_posix())
|
|
entries = sorted(set(entries))
|
|
created_at = datetime.now(UTC).isoformat()
|
|
digest = _sha256(archive)
|
|
manifest = {
|
|
"schemaVersion": "1.0",
|
|
"artifact": archive.name,
|
|
"sha256": digest,
|
|
"bytes": archive.stat().st_size,
|
|
"createdAt": created_at,
|
|
"entries": entries,
|
|
"metadata": sanitize_fields(dict(metadata or {})),
|
|
"encryption": "required-before-export"
|
|
if self.policy.encryption_required_before_export
|
|
else "not-required",
|
|
"retention": {
|
|
"days": self.policy.retention_days,
|
|
"maxArtifacts": self.policy.max_artifacts,
|
|
},
|
|
}
|
|
manifest_path.write_text(
|
|
json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
|
)
|
|
archive.with_suffix(archive.suffix + ".sha256").write_text(
|
|
f"{digest} {archive.name}\n", encoding="utf-8"
|
|
)
|
|
return BackupArtifact(
|
|
archive, manifest_path, digest, archive.stat().st_size, tuple(entries), created_at
|
|
)
|
|
|
|
def verify(self, artifact: BackupArtifact | Path) -> BackupArtifact:
|
|
archive = artifact.archive if isinstance(artifact, BackupArtifact) else artifact
|
|
manifest_path = archive.with_name(archive.name.removesuffix(".tar.gz") + ".manifest.json")
|
|
if not archive.is_file() or not manifest_path.is_file():
|
|
raise BackupError("backup archive and manifest are both required")
|
|
raw = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
digest = _sha256(archive)
|
|
if raw.get("sha256") != digest or raw.get("bytes") != archive.stat().st_size:
|
|
raise BackupError("backup integrity metadata does not match archive")
|
|
entries = tuple(str(item) for item in raw.get("entries", []))
|
|
if any(Path(item).is_absolute() or ".." in Path(item).parts for item in entries):
|
|
raise BackupError("backup manifest contains an unsafe entry")
|
|
return BackupArtifact(
|
|
archive, manifest_path, digest, archive.stat().st_size, entries, str(raw["createdAt"])
|
|
)
|
|
|
|
def restore_clean(self, artifact: BackupArtifact | Path, target_root: Path) -> BackupArtifact:
|
|
verified = self.verify(artifact)
|
|
target = target_root.resolve()
|
|
if target.exists() and any(target.iterdir()):
|
|
raise BackupError("clean restore target must not contain existing files")
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
with tarfile.open(verified.archive, "r:gz") as bundle:
|
|
members = bundle.getmembers()
|
|
for member in members:
|
|
member_path = Path(member.name)
|
|
if (
|
|
member_path.is_absolute()
|
|
or ".." in member_path.parts
|
|
or member.issym()
|
|
or member.islnk()
|
|
):
|
|
raise BackupError("archive contains an unsafe member")
|
|
if member.isdir():
|
|
continue
|
|
destination = (target / member_path).resolve()
|
|
try:
|
|
destination.relative_to(target)
|
|
except ValueError as exc:
|
|
raise BackupError("archive member escapes restore target") from exc
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
source = bundle.extractfile(member)
|
|
if source is None:
|
|
raise BackupError("archive member could not be read")
|
|
with destination.open("wb") as output:
|
|
shutil.copyfileobj(source, output)
|
|
return verified
|
|
|
|
def prune(self, *, now: datetime | None = None) -> list[Path]:
|
|
current = now or datetime.now(UTC)
|
|
manifests = sorted(
|
|
self.backup_root.glob("*.manifest.json"),
|
|
key=lambda item: item.stat().st_mtime,
|
|
reverse=True,
|
|
)
|
|
removed: list[Path] = []
|
|
for index, manifest in enumerate(manifests):
|
|
too_old = datetime.fromtimestamp(manifest.stat().st_mtime, UTC) < current - timedelta(
|
|
days=self.policy.retention_days
|
|
)
|
|
if index >= self.policy.max_artifacts or too_old:
|
|
archive = manifest.with_name(
|
|
manifest.name.removesuffix(".manifest.json") + ".tar.gz"
|
|
)
|
|
for path in (manifest, archive, archive.with_suffix(archive.suffix + ".sha256")):
|
|
if path.exists():
|
|
path.unlink()
|
|
removed.append(path)
|
|
return removed
|
|
|
|
|
|
def build_pg_dump_command(
|
|
database_name: str, output_path: Path, *, executable: str = "pg_dump"
|
|
) -> list[str]:
|
|
"""Build a password-free command; libpq reads credentials from its mounted service file."""
|
|
|
|
if not database_name or database_name.startswith("-") or "/" in database_name:
|
|
raise BackupError("database name is not a safe identifier")
|
|
if output_path.name != output_path.name.replace(" ", "") or output_path.suffix != ".dump":
|
|
raise BackupError("database dump output must be a simple .dump filename")
|
|
return [
|
|
executable,
|
|
"--format=custom",
|
|
"--no-owner",
|
|
"--no-privileges",
|
|
"--dbname",
|
|
database_name,
|
|
"--file",
|
|
str(output_path),
|
|
]
|
|
|
|
|
|
def run_pg_dump(
|
|
database_name: str, output_path: Path, *, environment: Mapping[str, str] | None = None
|
|
) -> None:
|
|
command = build_pg_dump_command(database_name, output_path)
|
|
safe_environment = dict(environment or os.environ)
|
|
subprocess.run(command, check=True, env=safe_environment, stdout=subprocess.DEVNULL) # noqa: S603
|