Files
rp/rp/core/transactional_filesystem.py
T

475 lines
14 KiB
Python

import shutil
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Any
import hashlib
import json
from collections import deque
@dataclass
class TransactionEntry:
action: str
path: str
timestamp: datetime
backup_path: Optional[str] = None
content_hash: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class OperationResult:
success: bool
path: Optional[str] = None
error: Optional[str] = None
affected_files: int = 0
transaction_id: Optional[str] = None
class TransactionContext:
"""Context manager for transactional filesystem operations."""
def __init__(self, filesystem: 'TransactionalFileSystem'):
self.filesystem = filesystem
self.transaction_id = str(uuid.uuid4())[:8]
self.start_time = datetime.now()
self.committed = False
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None:
self.filesystem.rollback_transaction(self.transaction_id)
else:
self.committed = True
return False
def commit(self):
"""Explicitly commit the transaction."""
self.committed = True
class TransactionalFileSystem:
"""
Atomic file write operations with rollback capability.
Prevents:
- Partial writes corrupting state
- Race conditions on file operations
- Directory traversal attacks
"""
def __init__(self, sandbox_root: str):
self.sandbox = Path(sandbox_root).resolve()
self.staging_dir = self.sandbox / '.staging'
self.backup_dir = self.sandbox / '.backups'
self.transaction_log: deque = deque(maxlen=1000)
self.transaction_states: Dict[str, List[TransactionEntry]] = {}
self.staging_dir.mkdir(parents=True, exist_ok=True)
self.backup_dir.mkdir(parents=True, exist_ok=True)
def begin_transaction(self) -> TransactionContext:
"""
Start atomic transaction with rollback capability.
Returns TransactionContext for use with 'with' statement
"""
context = TransactionContext(self)
self.transaction_states[context.transaction_id] = []
return context
def write_file_safe(
self,
filepath: str,
content: str,
transaction_id: Optional[str] = None,
) -> OperationResult:
"""
Atomic file write with validation and rollback.
Args:
filepath: Path relative to sandbox
content: File content to write
transaction_id: Optional transaction ID for grouping operations
Returns:
OperationResult with success/error status
"""
try:
target_path = self._validate_and_resolve_path(filepath)
target_path.parent.mkdir(parents=True, exist_ok=True)
staging_file = self.staging_dir / f"{uuid.uuid4()}.tmp"
try:
staging_file.write_text(content, encoding='utf-8')
backup_path = None
if target_path.exists():
backup_path = self._create_backup(target_path, transaction_id)
shutil.move(str(staging_file), str(target_path))
content_hash = self._hash_content(content)
entry = TransactionEntry(
action='write',
path=filepath,
timestamp=datetime.now(),
backup_path=backup_path,
content_hash=content_hash,
metadata={'size': len(content), 'encoding': 'utf-8'},
)
self.transaction_log.append(entry)
if transaction_id and transaction_id in self.transaction_states:
self.transaction_states[transaction_id].append(entry)
return OperationResult(
success=True,
path=str(target_path),
affected_files=1,
transaction_id=transaction_id,
)
except Exception as e:
staging_file.unlink(missing_ok=True)
raise
except Exception as e:
return OperationResult(
success=False,
error=str(e),
transaction_id=transaction_id,
)
def mkdir_safe(
self,
dirpath: str,
transaction_id: Optional[str] = None,
) -> OperationResult:
"""
Replace shell mkdir with Python pathlib.
Eliminates brace expansion errors.
Args:
dirpath: Directory path relative to sandbox
transaction_id: Optional transaction ID
Returns:
OperationResult with success/error status
"""
try:
target_dir = self._validate_and_resolve_path(dirpath)
target_dir.mkdir(parents=True, exist_ok=True)
entry = TransactionEntry(
action='mkdir',
path=dirpath,
timestamp=datetime.now(),
metadata={'recursive': True},
)
self.transaction_log.append(entry)
if transaction_id and transaction_id in self.transaction_states:
self.transaction_states[transaction_id].append(entry)
return OperationResult(
success=True,
path=str(target_dir),
affected_files=1,
transaction_id=transaction_id,
)
except Exception as e:
return OperationResult(
success=False,
error=str(e),
transaction_id=transaction_id,
)
def read_file_safe(self, filepath: str) -> OperationResult:
"""
Safe file read with path validation.
Args:
filepath: Path relative to sandbox
Returns:
OperationResult with file content on success
"""
try:
target_path = self._validate_and_resolve_path(filepath)
if not target_path.exists():
return OperationResult(
success=False,
error=f"File not found: {filepath}",
)
content = target_path.read_text(encoding='utf-8')
return OperationResult(
success=True,
path=str(target_path),
metadata={'content': content, 'size': len(content)},
)
except Exception as e:
return OperationResult(success=False, error=str(e))
def rollback_transaction(self, transaction_id: str) -> OperationResult:
"""
Rollback all operations in a transaction.
Restores backups and removes created files in reverse order.
Args:
transaction_id: Transaction ID to rollback
Returns:
OperationResult indicating rollback success
"""
if transaction_id not in self.transaction_states:
return OperationResult(
success=False,
error=f"Transaction {transaction_id} not found",
)
entries = self.transaction_states[transaction_id]
rollback_count = 0
for entry in reversed(entries):
try:
if entry.action == 'write':
target_path = self.sandbox / entry.path
target_path.unlink(missing_ok=True)
if entry.backup_path:
backup_path = Path(entry.backup_path)
if backup_path.exists():
shutil.copy(str(backup_path), str(target_path))
rollback_count += 1
elif entry.action == 'mkdir':
target_dir = self.sandbox / entry.path
if target_dir.exists() and not any(target_dir.iterdir()):
target_dir.rmdir()
rollback_count += 1
except Exception as e:
pass
del self.transaction_states[transaction_id]
return OperationResult(
success=True,
affected_files=rollback_count,
transaction_id=transaction_id,
)
def delete_file_safe(
self,
filepath: str,
transaction_id: Optional[str] = None,
) -> OperationResult:
"""
Safe file deletion with backup before removal.
Args:
filepath: Path relative to sandbox
transaction_id: Optional transaction ID
Returns:
OperationResult with success/error status
"""
try:
target_path = self._validate_and_resolve_path(filepath)
if not target_path.exists():
return OperationResult(
success=False,
error=f"File not found: {filepath}",
)
backup_path = self._create_backup(target_path, transaction_id)
target_path.unlink()
entry = TransactionEntry(
action='delete',
path=filepath,
timestamp=datetime.now(),
backup_path=backup_path,
metadata={'deleted': True},
)
self.transaction_log.append(entry)
if transaction_id and transaction_id in self.transaction_states:
self.transaction_states[transaction_id].append(entry)
return OperationResult(
success=True,
path=str(target_path),
affected_files=1,
transaction_id=transaction_id,
)
except Exception as e:
return OperationResult(
success=False,
error=str(e),
transaction_id=transaction_id,
)
def _validate_and_resolve_path(self, filepath: str) -> Path:
"""
Prevent directory traversal attacks and validate paths.
Security requirement for production systems.
Args:
filepath: Requested file path
Returns:
Resolved Path object within sandbox
Raises:
ValueError: If path is outside sandbox or invalid
"""
requested_path = (self.sandbox / filepath).resolve()
if not str(requested_path).startswith(str(self.sandbox)):
raise ValueError(f"Path outside sandbox: {filepath}")
if any(part.startswith('.') for part in requested_path.parts[1:]):
if not part.startswith('.staging') and not part.startswith('.backups'):
raise ValueError(f"Hidden directories not allowed: {filepath}")
return requested_path
def _create_backup(
self,
file_path: Path,
transaction_id: Optional[str] = None,
) -> str:
"""
Create backup of existing file before modification.
Args:
file_path: Path to file to backup
transaction_id: Optional transaction ID for organization
Returns:
Path to backup file
"""
if not file_path.exists():
return ""
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_filename = f"{file_path.name}_{timestamp}_{uuid.uuid4().hex[:8]}.bak"
if transaction_id:
backup_dir = self.backup_dir / transaction_id
backup_dir.mkdir(exist_ok=True)
else:
backup_dir = self.backup_dir
backup_path = backup_dir / backup_filename
shutil.copy2(str(file_path), str(backup_path))
return str(backup_path)
def _restore_backup(self, backup_path: str, original_path: str) -> bool:
"""
Restore file from backup.
Args:
backup_path: Path to backup file
original_path: Path to restore to
Returns:
True if successful
"""
try:
backup = Path(backup_path)
original = Path(original_path)
if backup.exists():
shutil.copy2(str(backup), str(original))
return True
return False
except Exception:
return False
def _hash_content(self, content: str) -> str:
"""
Calculate SHA256 hash of content.
Args:
content: Content to hash
Returns:
Hex string of hash
"""
return hashlib.sha256(content.encode('utf-8')).hexdigest()
def get_transaction_log(self, limit: int = 100) -> List[Dict]:
"""
Retrieve recent transaction log entries.
Args:
limit: Maximum number of entries to return
Returns:
List of transaction entries as dicts
"""
entries = []
for entry in list(self.transaction_log)[-limit:]:
entries.append({
'action': entry.action,
'path': entry.path,
'timestamp': entry.timestamp.isoformat(),
'backup_path': entry.backup_path,
'content_hash': entry.content_hash,
'metadata': entry.metadata,
})
return entries
def cleanup_old_backups(self, days_to_keep: int = 7) -> int:
"""
Remove backups older than specified number of days.
Args:
days_to_keep: Age threshold in days
Returns:
Number of backup files removed
"""
from datetime import timedelta
removed_count = 0
cutoff_time = datetime.now() - timedelta(days=days_to_keep)
for backup_file in self.backup_dir.rglob('*.bak'):
try:
mtime = datetime.fromtimestamp(backup_file.stat().st_mtime)
if mtime < cutoff_time:
backup_file.unlink()
removed_count += 1
except Exception:
pass
return removed_count