from __future__ import annotations import argparse import hashlib import json import os import re import shutil import stat import sys from dataclasses import dataclass from pathlib import Path from typing import Any DEFAULT_SOURCE = Path("/opt/matrix-screen-controller/data") DEFAULT_TARGET = Path("/var/lib/matrix-screen-controller") STAGING_NAME = ".matrix-screen-controller.migrate" ROOT_RUNTIME_FILES = { "last_frame.png", "startup_indicator.json", "startup_indicator.json.tmp", "config.json.tmp", } ROOT_RUNTIME_DIRECTORIES = {"runtime"} ATOMIC_TEMP_RE = re.compile(r"^\..+\.[0-9a-f]{32}\.tmp$") class StateRootMigrationError(RuntimeError): """Raised when the state root cannot be migrated without data loss.""" @dataclass(frozen=True) class MigrationResult: status: str source: Path target: Path file_count: int total_bytes: int def to_dict(self) -> dict[str, Any]: return { "status": self.status, "source": str(self.source), "target": str(self.target), "file_count": self.file_count, "total_bytes": self.total_bytes, } def _is_transient(relative: Path) -> bool: if relative.parts and relative.parts[0] in ROOT_RUNTIME_DIRECTORIES: return True if len(relative.parts) == 1 and relative.name in ROOT_RUNTIME_FILES: return True return bool(ATOMIC_TEMP_RE.fullmatch(relative.name)) 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 build_manifest(root: Path) -> dict[str, dict[str, Any]]: root = Path(root) if not root.exists(): return {} if not root.is_dir() or root.is_symlink(): raise StateRootMigrationError(f"state root must be a real directory: {root}") manifest: dict[str, dict[str, Any]] = {} for path in sorted(root.rglob("*")): relative = path.relative_to(root) if _is_transient(relative): continue if path.is_symlink(): raise StateRootMigrationError(f"state root contains an unsupported symlink: {relative}") if path.is_dir(): manifest[relative.as_posix()] = {"type": "directory"} continue if not path.is_file(): raise StateRootMigrationError(f"state root contains an unsupported entry: {relative}") size = path.stat().st_size manifest[relative.as_posix()] = { "type": "file", "size": size, "sha256": _sha256(path), } return manifest def _summary(manifest: dict[str, dict[str, Any]]) -> tuple[int, int]: files = [entry for entry in manifest.values() if entry["type"] == "file"] return len(files), sum(int(entry["size"]) for entry in files) def _copy_ignore(source: Path): def ignore(directory: str, names: list[str]) -> set[str]: base = Path(directory) ignored: set[str] = set() for name in names: relative = (base / name).relative_to(source) if _is_transient(relative): ignored.add(name) return ignored return ignore def _fsync_directory(path: Path) -> None: if os.name == "nt": return descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) try: os.fsync(descriptor) finally: os.close(descriptor) def _fsync_tree(root: Path) -> None: if os.name == "nt": return directories = [root] for path in root.rglob("*"): if path.is_file(): descriptor = os.open(path, os.O_RDONLY) try: os.fsync(descriptor) finally: os.close(descriptor) elif path.is_dir(): directories.append(path) for directory in reversed(directories): _fsync_directory(directory) def _assert_staging_path(staging: Path, target: Path) -> None: if staging.parent != target.parent or staging.name != STAGING_NAME: raise StateRootMigrationError(f"refusing to clean unexpected staging path: {staging}") def _remove_staging(staging: Path, target: Path) -> None: if not staging.exists(): return _assert_staging_path(staging, target) if staging.is_symlink() or not staging.is_dir(): raise StateRootMigrationError(f"refusing to replace unexpected staging entry: {staging}") shutil.rmtree(staging) def migrate_state_root(source: Path, target: Path) -> MigrationResult: source_input = Path(source) target_input = Path(target) if source_input.is_symlink() or target_input.is_symlink(): raise StateRootMigrationError("source and target state roots must not be symlinks") source = source_input.resolve(strict=False) target = target_input.resolve(strict=False) if source == target: raise StateRootMigrationError("source and target state roots must be different") if source in target.parents or target in source.parents: raise StateRootMigrationError("source and target state roots must not contain each other") source_manifest = build_manifest(source) if not source.exists(): target_manifest = build_manifest(target) file_count, total_bytes = _summary(target_manifest) if file_count == 0: raise StateRootMigrationError( "legacy source is missing and target contains no persistent files" ) return MigrationResult("target-only", source, target, file_count, total_bytes) source_file_count, _source_total_bytes = _summary(source_manifest) if source_file_count == 0: raise StateRootMigrationError("legacy source contains no persistent files") target_manifest = build_manifest(target) if target_manifest: if target_manifest != source_manifest: raise StateRootMigrationError( "source and target both contain different state; refusing to merge or overwrite" ) file_count, total_bytes = _summary(target_manifest) return MigrationResult("already-prepared", source, target, file_count, total_bytes) target_parent = target.parent target_parent.mkdir(parents=True, exist_ok=True) staging = target_parent / STAGING_NAME _remove_staging(staging, target) if target.exists(): if target.is_symlink() or not target.is_dir(): raise StateRootMigrationError(f"target state root is not a real directory: {target}") if any(target.iterdir()): raise StateRootMigrationError(f"target state root is not empty: {target}") try: shutil.copytree( source, staging, copy_function=shutil.copy2, ignore=_copy_ignore(source), ) copied_manifest = build_manifest(staging) if copied_manifest != source_manifest: raise StateRootMigrationError("copied state failed SHA-256 inventory verification") _fsync_tree(staging) if target.exists(): target.rmdir() os.replace(staging, target) if os.name != "nt": os.chmod(target, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP) _fsync_directory(target_parent) except Exception: _remove_staging(staging, target) raise file_count, total_bytes = _summary(source_manifest) return MigrationResult("migrated", source, target, file_count, total_bytes) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( "Copy the legacy Matrix Screen Controller data directory to its persistent " "Linux state root without deleting the source." ) ) parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE) parser.add_argument("--target", type=Path, default=DEFAULT_TARGET) return parser def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) try: result = migrate_state_root(args.source, args.target) except (OSError, StateRootMigrationError) as exc: print(f"state migration failed: {exc}", file=sys.stderr) return 1 print(json.dumps(result.to_dict(), ensure_ascii=True, sort_keys=True)) return 0 if __name__ == "__main__": raise SystemExit(main())