Research-Stack/4-Infrastructure/NoDupeLabs/nodupe/tools/maintenance/snapshot.py

431 lines
12 KiB
Python

"""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/<hash>`
3. Snapshots are stored at: `snapshots/<id>.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/<hash>` 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" / "<snapshot_id>.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/<hash> /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/<hash>
3. Snapshots are stored at: snapshots/<id>.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/<hash> to original path
Recovery script (Python):
---
import json, shutil
from pathlib import Path
backup_dir = Path(".nodupe/backups")
snapshot_file = backup_dir / "snapshots" / "<snapshot_id>.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/<hash> /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