#!/usr/bin/env python3 """Shared, storage-agnostic safety helpers for approved file recovery tools. This module deliberately contains no ``_System_File`` mutation policy. Recovery entrypoints own their metadata contract; this module only provides immutable input/output binding and byte-verified S3 transfer primitives. """ from __future__ import annotations import hashlib import json import os from pathlib import Path import shutil import subprocess import tempfile from typing import Any from common import DeployError def file_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 write_json_exclusive(path: Path, value: dict[str, Any]) -> str: path.parent.mkdir(parents=True, exist_ok=True) flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL descriptor = os.open(path, flags, 0o600) try: with os.fdopen(descriptor, "w", encoding="utf-8") as handle: json.dump(value, handle, indent=2, sort_keys=True) handle.write("\n") handle.flush() os.fsync(handle.fileno()) except BaseException: try: path.unlink() except FileNotFoundError: pass raise return file_sha256(path) def require_sha256(value: Any, context: str) -> str: normalized = str(value or "").strip().lower() if len(normalized) != 64 or any( char not in "0123456789abcdef" for char in normalized ): raise DeployError(f"{context} must be a lowercase SHA-256") return normalized def load_hash_bound_json( path: Path, expected_sha256: str, context: str, ) -> dict[str, Any]: expected_sha = require_sha256(expected_sha256, f"expected {context} hash") raw = path.read_bytes() actual_sha = hashlib.sha256(raw).hexdigest() if actual_sha != expected_sha: raise DeployError( f"{context} SHA-256 mismatch: expected {expected_sha}, got {actual_sha}" ) try: value = json.loads(raw) except json.JSONDecodeError as exc: raise DeployError(f"invalid {context} JSON: {exc}") from exc if not isinstance(value, dict): raise DeployError(f"{context} must be a JSON object") return value def require_exact_keys( value: dict[str, Any], expected: set[str], context: str, ) -> None: actual = set(value) if actual != expected: raise DeployError( f"{context} keys mismatch: missing={sorted(expected - actual)} " f"unexpected={sorted(actual - expected)}" ) def aws_path(aws_cli: str = "aws") -> str: resolved = shutil.which(aws_cli) if not resolved: raise DeployError(f"missing {aws_cli} command") return resolved def upload_s3_object( *, source_path: Path, bucket: str, key: str, region: str, sha256: str, aws_cli: str = "aws", ) -> None: command = [ aws_path(aws_cli), "s3api", "put-object", "--bucket", bucket, "--key", key, "--body", str(source_path), "--if-none-match", "*", "--checksum-algorithm", "SHA256", "--metadata", f"sha256={sha256}", ] if region: command.extend(["--region", region]) result = subprocess.run(command, text=True, capture_output=True, check=False) if result.returncode != 0: message = (result.stderr or result.stdout or "").strip() raise DeployError(f"failed to write recovery S3 object: {message[:1000]}") def verify_s3_object_bytes( *, bucket: str, key: str, region: str, expected_size_bytes: int, expected_sha256: str, aws_cli: str = "aws", ) -> None: with tempfile.TemporaryDirectory(prefix="nexuscrm-file-recovery-") as tmp: output_path = Path(tmp) / "object" command = [ aws_path(aws_cli), "s3api", "get-object", "--bucket", bucket, "--key", key, "--checksum-mode", "ENABLED", ] if region: command.extend(["--region", region]) command.append(str(output_path)) result = subprocess.run(command, text=True, capture_output=True, check=False) if result.returncode != 0: message = (result.stderr or result.stdout or "").strip() raise DeployError(f"failed to read recovery S3 object: {message[:1000]}") actual_size = output_path.stat().st_size if actual_size != expected_size_bytes: raise DeployError( "recovery S3 byte-size mismatch: " f"expected {expected_size_bytes}, got {actual_size}" ) actual_sha = file_sha256(output_path) if actual_sha != expected_sha256: raise DeployError("recovery S3 SHA-256 mismatch")