pig.py/filevault.py
Russell Ballestrini bc11c516b6 Add filevault system with async wrapper and convert sync ops to async
New files:
- filevault.py: Hash-based file storage with use_pairs option (v1.1.0)
- async_filevault.py: Async wrapper using asyncio.to_thread()
- domain_vault.py: Triple vault system for web archival (HTML, Media, Linkpeek)
- screenshot.py: Async screenshot capture using uri2png
- tests/unit/: Comprehensive test suite (85 tests)

Sync-to-async conversions:
- storage.py: Wrap Path operations in asyncio.to_thread()
- domain_vault.py: Wrap exists(), mkdir(), rglob(), os.walk() in asyncio.to_thread()
- screenshot.py: Wrap read_bytes(), write_bytes(), unlink() in asyncio.to_thread()

All sync filesystem operations now run in thread pool to avoid blocking async loop.
2025-12-22 20:18:20 -05:00

545 lines
18 KiB
Python

"""
FileVault 1.1.0 - Python 2/3 compatible hash-based file storage system
A Vault manages a hash directory tree of files on a filesystem.
Features:
- Create hash directory trees of custom depth
- Spread out files to keep CLI snappy when traversing the tree
- Scale to hundreds of thousands of files
- Obfuscate directory paths and filenames
- Compatible with Python 2.7+ and Python 3.x
"""
from __future__ import unicode_literals, print_function
import sys
import json
import fcntl
import contextlib
import tempfile
from os import path, makedirs, rename
from uuid import uuid4
from hashlib import sha256
from itertools import permutations
# Python 2/3 compatibility
if sys.version_info[0] == 3:
string_types = str
def ensure_bytes(s):
if isinstance(s, str):
return s.encode("utf-8")
return s
else:
string_types = basestring
def ensure_bytes(s):
if isinstance(s, unicode):
return s.encode("utf-8")
return s
__version__ = "1.1.0"
__author__ = "Russell Ballestrini"
__email__ = "russell@ballestrini.net"
__license__ = "Public Domain"
HEX = "0123456789abcdef"
class Vault(object):
"""
Hash-based file storage system with deterministic directory structure.
Creates a directory tree using hex characters for balanced file distribution.
Includes thread-safe file locking and atomic write operations.
Optional in-memory existence cache to avoid repeated filesystem stat() calls.
Directory modes:
- use_pairs=False (default): Single hex chars per level (16 dirs/level)
Example: depth=3 -> a/b/c/hash
- use_pairs=True: Hex pairs per level (256 dirs/level, git-style)
Example: depth=3 -> ab/cd/ef/hash
"""
def __init__(
self, vaultpath="vault", depth=3, salt="changeme", enable_memory_cache=False,
use_pairs=False
):
"""
Initialize a new Vault instance.
Args:
vaultpath (str): Base path for vault storage (default: 'vault')
depth (int): Directory tree depth (default: 3)
salt (str): Salt for hash generation (default: 'changeme')
enable_memory_cache (bool): Enable in-memory file existence cache (default: False)
use_pairs (bool): Use hex pairs for directories instead of single chars (default: False)
Pairs give 256 subdirs/level vs 16, better for large vaults
"""
self.vaultpath = vaultpath
self.depth = depth
self.salt = ensure_bytes(salt)
self.enable_memory_cache = enable_memory_cache
self.use_pairs = use_pairs
# In-memory file existence cache (optional performance optimization)
self._existence_cache = {} if enable_memory_cache else None
self.init_vault()
def init_vault(self):
"""Build the vault base directory if it doesn't exist."""
if not path.exists(self.vaultpath):
try:
makedirs(self.vaultpath)
except OSError:
pass
def _generate_filename(self, h, ext="", absolute=False):
"""
Accept a hash, return a valid file path.
Args:
h (str): Hash string
ext (str): File extension (optional)
absolute (bool): Return absolute path if True
Returns:
str: Generated file path
"""
if self.use_pairs:
# Pairs mode: take 2 chars at a time (256 dirs/level, git-style)
dirs = [h[i:i+2] for i in range(0, self.depth * 2, 2)]
else:
# Original mode: single chars (16 dirs/level)
dirs = [h[i] for i in range(self.depth)]
if ext and not ext.startswith("."):
ext = "." + ext
if absolute:
return path.join(self.vaultpath, *dirs) + "/" + h + ext
return path.join(*dirs) + "/" + h + ext
def create_filename(self, seed, ext="", absolute=False):
"""
Create a valid vault filename seeded with input.
Args:
seed (str): Seed string for deterministic hash
ext (str): Optional file extension
absolute (bool): Return absolute path if True
Returns:
str: Generated filename path
"""
seed_bytes = ensure_bytes(seed)
h = sha256(seed_bytes + self.salt).hexdigest()
return self._generate_filename(h, ext, absolute)
def create_random_filename(self, ext="", absolute=False):
"""
Create a valid vault filename seeded with random input.
Args:
ext (str): Optional file extension
absolute (bool): Return absolute path if True
Returns:
str: Generated random filename path
"""
random_seed = ensure_bytes(str(uuid4()))
h = sha256(random_seed + self.salt).hexdigest()
return self._generate_filename(h, ext, absolute)
@contextlib.contextmanager
def file_lock(self, file_path, mode="r", timeout=30):
"""
Context manager for file locking with automatic release.
Args:
file_path (str): Path to file to lock
mode (str): File open mode ('r', 'w', 'a', etc.)
timeout (int): Lock timeout in seconds
Yields:
file: Locked file object
Raises:
IOError: If lock cannot be acquired within timeout
"""
# Ensure directory exists for write operations
if "w" in mode or "a" in mode:
dir_path = path.dirname(file_path)
if dir_path and not path.exists(dir_path):
try:
makedirs(dir_path)
except OSError:
pass
# Open file and acquire lock
with open(file_path, mode) as f:
try:
# Use exclusive lock for write operations, shared for read
lock_type = (
fcntl.LOCK_EX if ("w" in mode or "a" in mode) else fcntl.LOCK_SH
)
fcntl.flock(f.fileno(), lock_type | fcntl.LOCK_NB)
yield f
except IOError as e:
if e.errno == 11 or e.errno == 35: # EAGAIN or EWOULDBLOCK
raise IOError(
"Could not acquire file lock for: {}".format(file_path)
)
raise
finally:
try:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
except:
pass # Ignore unlock errors
def atomic_write_json(self, file_path, data, indent=2, aliases=None):
"""
Atomically write JSON data to a file with proper locking.
Uses a temporary file + rename for atomic operations and fcntl for locking.
Args:
file_path (str): Target file path
data (dict/list): Data to write as JSON
indent (int): JSON indentation level
aliases (list): Optional list of alias keys that will create symlinks to this file
Raises:
IOError: If file operations fail
ValueError: If data cannot be serialized to JSON
"""
# Ensure target directory exists
dir_path = path.dirname(file_path)
if dir_path and not path.exists(dir_path):
try:
makedirs(dir_path)
except OSError:
pass
# Create temporary file in same directory for atomic rename
temp_fd, temp_path = tempfile.mkstemp(
dir=dir_path or ".",
prefix=".tmp_" + path.basename(file_path) + "_",
suffix=".json",
)
try:
with open(temp_path, "w") as temp_file:
# Lock the temporary file
fcntl.flock(temp_file.fileno(), fcntl.LOCK_EX)
# Write JSON data
json.dump(data, temp_file, indent=indent)
temp_file.flush()
# Force write to disk
import os
os.fsync(temp_file.fileno())
# Atomic rename - this is the commit point
rename(temp_path, file_path)
# Mark file as existing in cache after successful write
self.mark_file_exists(file_path, True)
# Create aliases (symlinks) if requested
if aliases:
for alias_key in aliases:
self._create_alias(file_path, alias_key)
except Exception as e:
# Clean up temporary file on any error
try:
import os
os.unlink(temp_path)
except:
pass
raise IOError("Failed to write JSON file {}: {}".format(file_path, str(e)))
finally:
# Close temp file descriptor
try:
import os
os.close(temp_fd)
except:
pass
def _create_alias(self, target_path, alias_key):
"""Create a symlink from alias_key to target_path"""
alias_path = self.create_filename(alias_key, ".json", absolute=True)
# Ensure alias directory exists
alias_dir = path.dirname(alias_path)
if alias_dir and not path.exists(alias_dir):
try:
makedirs(alias_dir)
except OSError:
pass
# Create or update symlink
rel_path = None # Initialize to avoid NameError in exception handler
try:
# Check if symlink already exists
if path.exists(alias_path) or path.islink(alias_path):
# Check if it points to the correct target
try:
if path.realpath(alias_path) == path.realpath(target_path):
# Already points to correct file, nothing to do
return
except:
pass
# Remove incorrect symlink
import os
os.unlink(alias_path)
# Create relative symlink so it works across directory structures
import os
# Calculate relative path from alias to target
alias_dir = path.dirname(alias_path)
rel_path = path.relpath(target_path, alias_dir)
os.symlink(rel_path, alias_path)
except Exception as e:
# Log but don't fail the write operation
import sys
import traceback
print(
"Warning: Failed to create alias {}: {}".format(alias_key, e),
file=sys.stderr,
)
print("Alias path: {}".format(alias_path), file=sys.stderr)
print("Target path: {}".format(target_path), file=sys.stderr)
if rel_path is not None:
print("Relative path: {}".format(rel_path), file=sys.stderr)
else:
print(
"Relative path: (not calculated due to earlier error)",
file=sys.stderr,
)
traceback.print_exc(file=sys.stderr)
def safe_read_json(self, file_path, default=None):
"""
Safely read JSON data from a file with proper locking.
Args:
file_path (str): Path to JSON file
default (any): Default value if file doesn't exist or is invalid
Returns:
dict/list: Parsed JSON data or default value
Raises:
IOError: If file cannot be read
ValueError: If JSON is malformed and no default provided
"""
if not path.exists(file_path):
if default is not None:
return default
raise IOError("File not found: {}".format(file_path))
try:
with self.file_lock(file_path, "r") as f:
return json.load(f)
except (IOError, ValueError) as e:
if default is not None:
return default
raise
def file_exists(self, file_path):
"""
Check if file exists, using memory cache if enabled.
Args:
file_path (str): Path to check
Returns:
bool: True if file exists, False otherwise
"""
if self.enable_memory_cache and self._existence_cache is not None:
# Check memory cache first
if file_path in self._existence_cache:
return self._existence_cache[file_path]
# Cache miss - check filesystem and cache result
exists = path.exists(file_path)
self._existence_cache[file_path] = exists
return exists
else:
# No cache - direct filesystem check
return path.exists(file_path)
def mark_file_exists(self, file_path, exists=True):
"""
Mark a file as existing or not existing in the cache.
This should be called after file operations to keep cache consistent.
Args:
file_path (str): Path to mark
exists (bool): Whether file exists (default: True)
"""
if self.enable_memory_cache and self._existence_cache is not None:
self._existence_cache[file_path] = exists
def clear_existence_cache(self):
"""
Clear the entire existence cache.
Useful for testing or when filesystem state may have changed externally.
"""
if self.enable_memory_cache and self._existence_cache is not None:
self._existence_cache.clear()
def get_cache_stats(self):
"""
Get statistics about the existence cache.
Returns:
dict: Cache statistics or None if cache disabled
"""
if self.enable_memory_cache and self._existence_cache is not None:
return {
"enabled": True,
"entries": len(self._existence_cache),
"hits": sum(1 for exists in self._existence_cache.values() if exists),
"misses": sum(
1 for exists in self._existence_cache.values() if not exists
),
}
return {"enabled": False}
def remove_file(self, file_path):
"""
Remove a file and update the memory cache.
Args:
file_path (str): Path to file to remove
Returns:
bool: True if file was removed, False if it didn't exist
Raises:
OSError: If file removal fails
"""
if not path.exists(file_path):
# Mark as not existing in cache even if file doesn't exist
self.mark_file_exists(file_path, False)
return False
try:
import os
os.remove(file_path)
# Mark as not existing in cache after successful removal
self.mark_file_exists(file_path, False)
return True
except OSError as e:
# Re-raise the error but don't update cache if removal failed
raise OSError("Failed to remove file {}: {}".format(file_path, str(e)))
def remove_file_if_exists(self, file_path):
"""
Remove a file if it exists, ignoring errors if file doesn't exist.
Args:
file_path (str): Path to file to remove
Returns:
bool: True if file was removed, False if it didn't exist
"""
try:
return self.remove_file(file_path)
except OSError:
# File didn't exist or couldn't be removed, mark as not existing
self.mark_file_exists(file_path, False)
return False
def purge_cache_for_path_pattern(self, path_pattern):
"""
Remove all cache entries matching a path pattern.
Useful for GDPR compliance when removing user data.
Args:
path_pattern (str): Pattern to match (simple string contains check)
"""
if self.enable_memory_cache and self._existence_cache is not None:
# Find all cache keys that contain the pattern
keys_to_remove = [
key for key in self._existence_cache.keys() if path_pattern in key
]
# Remove matching entries
for key in keys_to_remove:
del self._existence_cache[key]
return len(keys_to_remove)
return 0
def write_text_file(self, file_path, content, mode="w"):
"""
Write text content to a file with proper locking.
Args:
file_path (str): Target file path
content (str): Text content to write
mode (str): File open mode ('w', 'a', etc.)
Raises:
IOError: If file operations fail
"""
with self.file_lock(file_path, mode) as f:
f.write(content)
f.flush()
import os
os.fsync(f.fileno())
# Mark file as existing in cache after successful write
self.mark_file_exists(file_path, True)
def read_text_file(self, file_path, default=None):
"""
Read text content from a file with proper locking.
Args:
file_path (str): Path to text file
default (str): Default value if file doesn't exist
Returns:
str: File content or default value
Raises:
IOError: If file cannot be read and no default provided
"""
if not path.exists(file_path):
if default is not None:
return default
raise IOError("File not found: {}".format(file_path))
try:
with self.file_lock(file_path, "r") as f:
return f.read()
except IOError:
if default is not None:
return default
raise
# For backwards compatibility
def create_vault(*args, **kwargs):
"""Factory function for creating Vault instances (backwards compatibility)."""
return Vault(*args, **kwargs)