from __future__ import annotations import argparse import json import shutil import sqlite3 import tempfile import zipfile from collections.abc import Sequence from contextlib import closing from pathlib import Path from tools.backup import sha256 def _safe_members(archive: zipfile.ZipFile) -> list[zipfile.ZipInfo]: members = archive.infolist() for member in members: path = Path(member.filename) if path.is_absolute() or ".." in path.parts: raise ValueError("backup contains an unsafe path") return members def restore_backup( archive_path: Path, database: Path, *, private_skills: Path | None = None, environment_file: Path | None = None, encryption_key_file: Path | None = None, overwrite: bool = False, ) -> dict: archive_path = archive_path.resolve() database = database.resolve() destinations = [database] destinations.extend(path.resolve() for path in (environment_file, encryption_key_file) if path) if private_skills: destinations.append(private_skills.resolve()) if not overwrite and any(path.exists() for path in destinations): raise FileExistsError( "restore destination exists; explicit overwrite confirmation is required" ) with tempfile.TemporaryDirectory(prefix="xiaobai-restore-") as raw: staging = Path(raw) with zipfile.ZipFile(archive_path) as archive: _safe_members(archive) archive.extractall(staging) manifest = json.loads((staging / "manifest.json").read_text(encoding="utf-8")) if manifest.get("format") != 1 or not isinstance(manifest.get("files"), dict): raise ValueError("backup manifest is invalid") for name, metadata in manifest["files"].items(): path = staging / name if not path.is_file() or sha256(path) != metadata.get("sha256"): raise ValueError(f"backup checksum mismatch: {name}") restored_db = staging / "database.sqlite3" with closing( sqlite3.connect(f"file:{restored_db.as_posix()}?mode=ro", uri=True) ) as connection: if connection.execute("PRAGMA integrity_check").fetchone()[0] != "ok": raise ValueError("restored database failed integrity check") database.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(restored_db, database) for destination, name in ( (environment_file, "secrets/environment"), (encryption_key_file, "secrets/app-encryption.key"), ): source = staging / name if destination and source.is_file(): destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, destination) skills_source = staging / "private-mentor-skills" if private_skills and skills_source.is_dir(): private_skills.mkdir(parents=True, exist_ok=True) shutil.copytree(skills_source, private_skills, dirs_exist_ok=True) return manifest def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Validate and restore a Xiaobai backup") parser.add_argument("--archive", type=Path, required=True) parser.add_argument("--database", type=Path, required=True) parser.add_argument("--private-skills", type=Path) parser.add_argument("--environment-file", type=Path) parser.add_argument("--encryption-key-file", type=Path) parser.add_argument("--confirm-restore", action="store_true") parser.add_argument("--confirm-overwrite", action="store_true") return parser def main(arguments: Sequence[str] | None = None) -> int: parsed = build_parser().parse_args(arguments) if not parsed.confirm_restore: raise SystemExit("restore requires --confirm-restore") manifest = restore_backup( parsed.archive, parsed.database, private_skills=parsed.private_skills, environment_file=parsed.environment_file, encryption_key_file=parsed.encryption_key_file, overwrite=parsed.confirm_overwrite, ) print( json.dumps( {"restored": str(parsed.database.resolve()), "files": len(manifest["files"])}, ensure_ascii=False, ) ) return 0 if __name__ == "__main__": raise SystemExit(main())