mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-07-31 03:05:21 +00:00
431 lines
12 KiB
Python
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
|