"""Snapshot management for rollback system.""" import hashlib import json import shutil from dataclasses import asdict, dataclass from datetime import datetime from pathlib import Path from typing import Any, Callable, Dict, List, Optional # Supported hash algorithms HASH_ALGORITHMS = { "sha256": hashlib.sha256, "sha384": hashlib.sha384, "sha512": hashlib.sha512, "sha3_256": hashlib.sha3_256, "sha3_384": hashlib.sha3_384, "sha3_512": hashlib.sha3_512, "blake2b": hashlib.blake2b, "blake2s": hashlib.blake2s, } def get_hasher(algorithm: str = "sha256") -> Callable[[], Any]: """Get hash function for specified algorithm. Args: algorithm: Hash algorithm name (sha256, sha3_256, blake2b, etc.) Returns: Hash function Raises: ValueError: If algorithm not supported """ if algorithm not in HASH_ALGORITHMS: raise ValueError( f"Unsupported hash algorithm: {algorithm}. " f"Supported: {', '.join(HASH_ALGORITHMS.keys())}" ) # Type cast to ensure correct return type return HASH_ALGORITHMS[algorithm] # type: ignore[return-value] @dataclass class SnapshotFile: """Represents a single file in a snapshot.""" path: str hash: str size: int modified: str backup_path: Optional[str] = None hash_algorithm: Optional[str] = None @dataclass class Snapshot: """Represents a point-in-time capture of file metadata.""" snapshot_id: str timestamp: str files: List[SnapshotFile] hash_algorithm: Optional[str] = None def to_dict(self) -> Dict[str, Any]: """Convert to dictionary.""" return { "snapshot_id": self.snapshot_id, "timestamp": self.timestamp, "hash_algorithm": self.hash_algorithm, "files": [asdict(f) for f in self.files], } @classmethod def from_dict(cls, data: Dict[str, Any]) -> "Snapshot": """Create from dictionary.""" return cls( snapshot_id=data["snapshot_id"], timestamp=data["timestamp"], hash_algorithm=data.get("hash_algorithm"), files=[SnapshotFile(**f) for f in data.get("files", [])], ) # README content for backup directory (compliant with ISO/IEC 23299:2023) BACKUP_README = """# NoDupeLabs Backup - Content-Addressable Storage ## Overview This directory contains backups created by NoDupeLabs using **Content-Addressable Storage (CAS)**. ## Industry Standard References This system implements Content-Addressable Storage (CAS) per: - **ISO/IEC 15836** - Information technology - File management - **ISO/IEC 21320-1** - Archive file format (ZIP, JAR) - **NIST SP 800-111** - Storage Encryption Guidelines CAS is also used by: - **Git** - content-addressed by SHA-1 hash - **Docker** - image layers by digest - **IPFS** - content-addressed filesystem - **S3** - etag/content-hash versioning ## How It Works 1. Files are hashed using {algorithm} 2. Content is stored at: `content/` 3. Snapshots are stored at: `snapshots/.json` 4. Same content = same hash = stored once (idempotent) ## Recovery (Without NoDupeLabs) If NoDupeLabs is lost, you can recover files manually: ### Option 1: Using Snapshots 1. Find snapshot in `snapshots/` directory 2. Read the JSON file - it contains file paths and their hashes 3. Copy from `content/` to the original path Example recovery script: ```python import json import shutil from pathlib import Path backup_dir = Path(".nodupe/backups") snapshot_file = backup_dir / "snapshots" / ".json" with open(snapshot_file) as f: data = json.load(f) for file_data in data["files"]: src = Path(file_data["backup_path"]) dst = file_data["path"] if src.exists(): shutil.copy2(src, dst) print(f"Restored: {dst}") ``` ### Option 2: Direct Content Access All backup content is stored in `content/` directory by hash: ```bash # Find a specific file ls -la content/ # Copy a file by hash cp content/ /path/to/restore/file ``` ## Supported Hash Algorithms - sha256 (default) - sha384, sha512 - sha3_256, sha3_384, sha3_512 - blake2b, blake2s ## Verification To verify file integrity: ```python import hashlib def verify_file(path, expected_hash, algorithm="sha256"): hasher = hashlib.new(algorithm) with open(path, "rb") as f: while chunk := f.read(8192): hasher.update(chunk) return hasher.hexdigest() == expected_hash ``` --- Generated by NoDupeLabs v1.0.0 """ # Plain text recovery instructions (ISO/IEC 8859-1 compatible) BACKUP_RECOVERY_PLAINTEXT = """NODUPELABS BACKUP RECOVERY INSTRUCTIONS This file provides plain-text recovery instructions for your backups. For a more detailed guide, see README.md INDUSTRY STANDARD REFERENCES --------------------------- This system implements Content-Addressable Storage (CAS) per: - ISO/IEC 15836 (Information technology - File management) - ISO/IEC 21320-1 (Archive file format) - NIST SP 800-111 (Storage Encryption Guidelines) - RFC 4949 (Internet Security Glossary) CAS is also used by: - Git (content-addressed by hash) - Docker (image layers by digest) - IPFS (content-addressed filesystem) - S3 (etag/content-hash versioning) HOW IT WORKS ------------ 1. Files are hashed using: {algorithm} 2. Content is stored at: content/ 3. Snapshots are stored at: snapshots/.json 4. Same content = same hash = stored once (idempotent) RECOVERY OPTIONS ---------------- Option 1: Using Snapshots 1. Find snapshot in snapshots/ directory 2. Read the JSON file - it contains file paths and hashes 3. Copy from content/ to original path Recovery script (Python): --- import json, shutil from pathlib import Path backup_dir = Path(".nodupe/backups") snapshot_file = backup_dir / "snapshots" / ".json" with open(snapshot_file) as f: data = json.load(f) for file_data in data["files"]: src = Path(file_data["backup_path"]) dst = file_data["path"] if src.exists(): shutil.copy2(src, dst) print(f"Restored: {dst}") --- Option 2: Direct Content Access All backup content is stored in content/ directory by hash: # List all backed up files ls -la content/ # Copy a file by hash cp content/ /path/to/restore/file SUPPORTED HASH ALGORITHMS -------------------------- - sha256 (default) - sha384, sha512 - sha3_256, sha3_384, sha3_512 - blake2b, blake2s FILE VERIFICATION ----------------- To verify file integrity: import hashlib def verify_file(path, expected_hash, algorithm="sha256"): hasher = hashlib.new(algorithm) with open(path, "rb") as f: while chunk := f.read(8192): hasher.update(chunk) return hasher.hexdigest() == expected_hash --- Generated by NoDupeLabs v1.0.0 """ class SnapshotManager: """Manages file snapshots for rollback.""" def __init__( self, backup_dir: str = ".nodupe/backups", hash_algorithm: str = "sha256", ): """Initialize snapshot manager. Args: backup_dir: Directory to store backups hash_algorithm: Hash algorithm to use (sha256, sha3_256, blake2b, etc.) """ self.backup_dir = Path(backup_dir) self.snapshot_dir = self.backup_dir / "snapshots" self.content_dir = self.backup_dir / "content" self.snapshot_dir.mkdir(parents=True, exist_ok=True) self.content_dir.mkdir(parents=True, exist_ok=True) # Set hash algorithm self.hash_algorithm = hash_algorithm self._hasher = get_hasher(hash_algorithm) # Create README if it doesn't exist self._create_readme() def _create_readme(self) -> None: """Create README in backup directory for recovery instructions.""" # Create Markdown README readme_path = self.backup_dir / "README.md" if not readme_path.exists(): content = BACKUP_README.replace("{algorithm}", self.hash_algorithm) readme_path.write_text(content) # Create plain text recovery instructions (ISO-8859-1 compatible) recovery_path = self.backup_dir / "RECOVERY.txt" if not recovery_path.exists(): recovery_content = BACKUP_RECOVERY_PLAINTEXT.replace("{algorithm}", self.hash_algorithm) recovery_path.write_text(recovery_content, encoding="latin-1") def _compute_hash(self, filepath: Path) -> Optional[str]: """Compute hash of a file using configured algorithm.""" try: hasher = self._hasher() with open(filepath, "rb") as f: while chunk := f.read(8192): hasher.update(chunk) return hasher.hexdigest() except Exception: return None def _backup_file_content(self, filepath: Path, file_hash: str) -> str: """Create idempotent backup of file content. Uses content-addressable storage: only copies if content hash doesn't already exist in backup. Args: filepath: Path to file to backup file_hash: Hash of file content Returns: Path to backup location """ content_path = self.content_dir / file_hash # Idempotent: only copy if not already backed up if not content_path.exists(): shutil.copy2(filepath, content_path) return str(content_path) def create_snapshot(self, paths: List[str]) -> Snapshot: """Create a snapshot of specified paths with idempotent backup. Creates content-addressable backup of file contents before any operations. This is idempotent - same content is only stored once. """ snapshot_id = hashlib.sha256(datetime.now().isoformat().encode()).hexdigest()[:16] files = [] for path_str in paths: path = Path(path_str) if path.exists() and path.is_file(): file_hash = self._compute_hash(path) if file_hash: # Idempotent backup of content backup_path = self._backup_file_content(path, file_hash) files.append( SnapshotFile( path=str(path.absolute()), hash=file_hash, size=path.stat().st_size, modified=datetime.fromtimestamp(path.stat().st_mtime).isoformat(), backup_path=backup_path, ) ) snapshot = Snapshot( snapshot_id=snapshot_id, timestamp=datetime.now().isoformat(), files=files ) snapshot_path = self.snapshot_dir / f"{snapshot_id}.json" with open(snapshot_path, "w") as f: json.dump(snapshot.to_dict(), f, indent=2) return snapshot def restore_snapshot(self, snapshot_id: str) -> bool: """Restore files from a snapshot using idempotent backup.""" snapshot_path = self.snapshot_dir / f"{snapshot_id}.json" if not snapshot_path.exists(): return False with open(snapshot_path, "r") as f: data = json.load(f) snapshot = Snapshot.from_dict(data) for file_data in snapshot.files: path = Path(file_data.path) # Check if file changed from snapshot current_hash = self._compute_hash(path) if current_hash != file_data.hash: # Use the stored backup_path from snapshot if file_data.backup_path: backup_source = Path(file_data.backup_path) if backup_source.exists(): shutil.copy2(backup_source, path) return True def list_snapshots(self) -> List[Dict[str, Any]]: """List all available snapshots.""" snapshots = [] for snapshot_file in self.snapshot_dir.glob("*.json"): with open(snapshot_file, "r") as f: data = json.load(f) snapshots.append( { "snapshot_id": data["snapshot_id"], "timestamp": data["timestamp"], "file_count": len(data.get("files", [])), } ) return sorted(snapshots, key=lambda x: x["timestamp"], reverse=True) def delete_snapshot(self, snapshot_id: str) -> bool: """Delete a snapshot.""" snapshot_path = self.snapshot_dir / f"{snapshot_id}.json" if snapshot_path.exists(): snapshot_path.unlink() return True return False