148 lines
5.4 KiB
Python
148 lines
5.4 KiB
Python
"""Atomic download cache with hash verification for Mosaic."""
|
|
import os
|
|
import hashlib
|
|
import shutil
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class DownloadCache:
|
|
"""Manages atomic downloads with hash-sharded storage and integrity verification."""
|
|
|
|
def __init__(self, base_dir: str = "output/raw"):
|
|
self.base_dir = Path(base_dir)
|
|
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
def get_shard_path(self, content_hash: str, collection: str, ext: str = "") -> Path:
|
|
"""Get hash-sharded storage path: raw/{collection}/{ab}/{cd}/{hash}.{ext}"""
|
|
if len(content_hash) < 4:
|
|
raise ValueError(f"Invalid hash: {content_hash}")
|
|
ab = content_hash[:2]
|
|
cd = content_hash[2:4]
|
|
filename = content_hash + (f".{ext}" if ext else "")
|
|
return self.base_dir / collection / ab / cd / filename
|
|
|
|
def store_atomic(self, tmp_path: str, content_hash: str, collection: str,
|
|
ext: str = "", expected_size: Optional[int] = None) -> str:
|
|
"""Atomically move a temp file to its cache location.
|
|
|
|
Args:
|
|
tmp_path: Path to the temporary download file
|
|
content_hash: SHA256 hash of the content
|
|
collection: Source collection name
|
|
ext: File extension
|
|
expected_size: Expected file size (from Content-Length)
|
|
|
|
Returns:
|
|
Final path of the cached file
|
|
|
|
Raises:
|
|
ValueError: If hash or size verification fails
|
|
"""
|
|
tmp = Path(tmp_path)
|
|
if not tmp.exists():
|
|
raise FileNotFoundError(f"Temp file not found: {tmp_path}")
|
|
|
|
# Verify size if expected
|
|
if expected_size is not None:
|
|
actual_size = tmp.stat().st_size
|
|
if actual_size != expected_size:
|
|
raise ValueError(
|
|
f"Size mismatch: expected {expected_size}, got {actual_size}"
|
|
)
|
|
|
|
# Verify hash
|
|
actual_hash = self.compute_hash(tmp_path)
|
|
if actual_hash != content_hash:
|
|
raise ValueError(
|
|
f"Hash mismatch: expected {content_hash}, got {actual_hash}"
|
|
)
|
|
|
|
# Atomic move to final location
|
|
final_path = self.get_shard_path(content_hash, collection, ext)
|
|
final_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
shutil.move(str(tmp), str(final_path))
|
|
logger.debug("Cached: %s → %s", tmp_path, final_path)
|
|
return str(final_path)
|
|
|
|
def is_cached(self, content_hash: str, collection: str, ext: str = "") -> bool:
|
|
"""Check if a file exists in cache with valid hash."""
|
|
path = self.get_shard_path(content_hash, collection, ext)
|
|
if not path.exists():
|
|
return False
|
|
actual_hash = self.compute_hash(str(path))
|
|
if actual_hash != content_hash:
|
|
logger.warning("Cache corruption detected: %s (expected %s, got %s)",
|
|
path, content_hash, actual_hash)
|
|
return False
|
|
return True
|
|
|
|
def get_cached_path(self, content_hash: str, collection: str, ext: str = "") -> Optional[str]:
|
|
"""Get path to cached file, or None if not cached/corrupted."""
|
|
if self.is_cached(content_hash, collection, ext):
|
|
return str(self.get_shard_path(content_hash, collection, ext))
|
|
return None
|
|
|
|
def verify_file(self, file_path: str, expected_hash: str) -> bool:
|
|
"""Verify a file's hash matches expected."""
|
|
actual = self.compute_hash(file_path)
|
|
return actual == expected_hash
|
|
|
|
def verify_all(self, collection: str) -> tuple[int, int, list[str]]:
|
|
"""Verify all files in a collection.
|
|
|
|
Returns:
|
|
(verified_count, mismatch_count, list_of_mismatched_paths)
|
|
"""
|
|
collection_dir = self.base_dir / collection
|
|
if not collection_dir.exists():
|
|
return 0, 0, []
|
|
|
|
verified = 0
|
|
mismatched = 0
|
|
bad_paths = []
|
|
|
|
for root, _, files in os.walk(collection_dir):
|
|
for fname in files:
|
|
if fname.startswith('.') or fname.endswith('.tmp'):
|
|
continue
|
|
fpath = os.path.join(root, fname)
|
|
# Extract expected hash from filename
|
|
expected_hash = Path(fname).stem
|
|
actual_hash = self.compute_hash(fpath)
|
|
if actual_hash == expected_hash:
|
|
verified += 1
|
|
else:
|
|
mismatched += 1
|
|
bad_paths.append(fpath)
|
|
|
|
return verified, mismatched, bad_paths
|
|
|
|
@staticmethod
|
|
def compute_hash(file_path: str) -> str:
|
|
"""Compute SHA256 hash of a file."""
|
|
sha256 = hashlib.sha256()
|
|
with open(file_path, 'rb') as f:
|
|
for chunk in iter(lambda: f.read(8192), b''):
|
|
sha256.update(chunk)
|
|
return sha256.hexdigest()
|
|
|
|
@staticmethod
|
|
def hash_content(content: bytes) -> str:
|
|
"""Compute SHA256 hash of bytes content."""
|
|
return hashlib.sha256(content).hexdigest()
|
|
|
|
def check_disk_space(self, required_mb: int = 100) -> bool:
|
|
"""Check if enough disk space is available."""
|
|
stat = shutil.disk_usage(str(self.base_dir))
|
|
free_mb = stat.free // (1024 * 1024)
|
|
if free_mb < required_mb:
|
|
logger.warning("Low disk space: %d MB free, %d MB required",
|
|
free_mb, required_mb)
|
|
return False
|
|
return True
|