Coverage for src/local_deep_research/security/file_integrity/integrity_manager.py: 98%
221 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1"""
2File Integrity Manager - Main service for file integrity verification.
4Provides smart verification with embedded statistics and sparse failure logging.
5"""
7from contextlib import contextmanager
8from datetime import datetime, UTC
9from pathlib import Path
10from typing import Optional, Tuple, List
11from loguru import logger
13from .base_verifier import BaseFileVerifier
14from ...database.models.file_integrity import (
15 FileIntegrityRecord,
16 FileVerificationFailure,
17)
19# The database session context is mandatory for integrity verification. Keep
20# this as a direct import: a circular import or other bootstrap/load-order
21# failure must propagate with its original traceback instead of being caught
22# and later replaced by misleading optional-Flask guidance.
23from ...database.session_context import get_user_db_session
26# Reason returned by verify_file when a file has no integrity record. Unknown
27# files are rejected (fail closed) rather than auto-registered, to avoid a
28# trust-on-first-use gap where deleted records or newly injected files would
29# otherwise bypass integrity verification.
30NO_INTEGRITY_RECORD = "no_integrity_record"
33class FileIntegrityManager:
34 """
35 Central service for file integrity verification.
37 Features:
38 - Smart verification (only verify if file modified)
39 - Embedded statistics (low overhead)
40 - Sparse failure logging (audit trail)
41 - Multi-verifier support (different file types)
42 - Automatic cleanup of old failure records
43 """
45 # Configuration for automatic cleanup
46 MAX_FAILURES_PER_FILE = 100 # Keep at most this many failures per file
47 MAX_TOTAL_FAILURES = 10000 # Global limit across all files
49 def __init__(self, username: str, password: Optional[str] = None):
50 """
51 Initialize file integrity manager.
53 Args:
54 username: Username for database access
55 password: Optional password for encrypted database
57 """
58 self.username = username
59 self.password = password
60 self.verifiers: List[BaseFileVerifier] = []
62 # Run startup cleanup to remove old failures
63 try:
64 deleted = self.cleanup_all_old_failures()
65 if deleted > 0:
66 logger.info(
67 f"[FILE_INTEGRITY] Startup cleanup: removed {deleted} old failure records"
68 )
69 except Exception:
70 logger.warning("[FILE_INTEGRITY] Startup cleanup failed")
72 @contextmanager
73 def _integrity_write(self, session, nested: bool):
74 """Wrap an integrity write so it settles correctly for the caller.
76 ``nested=False`` (default, standalone callers): commit the
77 (thread-local) session — immediate durability.
79 ``nested=True``: run the write inside a SAVEPOINT and DON'T commit, so
80 the caller's outer transaction commits it. REQUIRED when record_file /
81 verify_file run reentrantly INSIDE another open transaction — the vector
82 store calls them during apply(), which has just-flushed, uncommitted
83 DocumentChunk rows on the SAME thread-local session. A plain commit
84 would settle those rows early, and a transient-failure rollback would
85 DISCARD them; a SAVEPOINT scopes any failure to just the integrity
86 write, leaving the caller's rows intact, and both commit together.
87 Same connection, so no second-writer SQLite contention.
88 """
89 if nested:
90 with session.begin_nested():
91 yield
92 else:
93 yield
94 session.commit()
96 @contextmanager
97 def _session_for(self, nested: bool):
98 """Yield the session for an integrity op.
100 Standalone (nested=False): a normal ``get_user_db_session``.
102 Reentrant (nested=True): the caller's LIVE thread-local session,
103 obtained via ``get_current_thread_session`` — NOT ``get_user_db_session``.
104 Re-entering ``get_user_db_session`` in a NON-request (background/
105 scheduler) context routes through ``ThreadSessionManager.get_session``,
106 which on re-acquisition runs a validation ``SELECT 1`` and then
107 ``session.rollback()`` to release the DEFERRED lock — that rollback
108 would discard the CALLER's just-flushed, uncommitted DocumentChunk rows
109 before our SAVEPOINT ever runs (silent data loss: vectors persisted,
110 text rows gone). The live-session accessor has no such side effect.
111 """
112 if nested:
113 from ...database.thread_local_session import (
114 get_current_thread_session,
115 )
117 existing = get_current_thread_session()
118 if existing is not None:
119 yield existing
120 return
121 with get_user_db_session(self.username, self.password) as session:
122 yield session
124 def _normalize_path(self, file_path: Path) -> str:
125 """
126 Normalize path for consistent storage and lookup.
128 Resolves symlinks, makes absolute, and normalizes separators
129 to ensure the same file is always represented the same way.
131 Args:
132 file_path: Path to normalize
134 Returns:
135 Normalized path string
136 """
137 return str(file_path.resolve())
139 def register_verifier(self, verifier: BaseFileVerifier) -> None:
140 """
141 Register a file type verifier.
143 Args:
144 verifier: Verifier instance to register
145 """
146 self.verifiers.append(verifier)
147 logger.debug(
148 f"[FILE_INTEGRITY] Registered verifier for type: {verifier.get_file_type()}"
149 )
151 def record_file(
152 self,
153 file_path: Path,
154 related_entity_type: Optional[str] = None,
155 related_entity_id: Optional[int] = None,
156 *,
157 nested: bool = False,
158 ) -> FileIntegrityRecord:
159 """
160 Create or update integrity record for a file.
162 Args:
163 file_path: Path to file to record
164 related_entity_type: Optional related entity type (e.g., 'rag_index')
165 related_entity_id: Optional related entity ID
167 Returns:
168 FileIntegrityRecord instance
170 Raises:
171 FileNotFoundError: If file doesn't exist
172 ValueError: If no verifier handles this file type
173 """
174 if not file_path.exists():
175 raise FileNotFoundError(f"File not found: {file_path}")
177 verifier = self._get_verifier_for_file(file_path)
178 if not verifier:
179 raise ValueError(f"No verifier registered for file: {file_path}")
181 # Calculate checksum and get file stats
182 checksum = verifier.calculate_checksum(file_path)
183 file_stat = file_path.stat()
184 normalized_path = self._normalize_path(file_path)
186 nested_error: Optional[Exception] = None
187 with self._session_for(nested) as session:
188 # Check if record exists (using normalized path)
189 record = (
190 session.query(FileIntegrityRecord)
191 .filter_by(file_path=normalized_path)
192 .first()
193 )
195 try:
196 with self._integrity_write(session, nested):
197 if record:
198 # Update existing record
199 record.checksum = checksum
200 record.file_size = file_stat.st_size
201 record.file_mtime = file_stat.st_mtime
202 record.algorithm = verifier.get_algorithm()
203 record.updated_at = datetime.now(UTC)
204 logger.info(
205 f"[FILE_INTEGRITY] Updated record for: {file_path}"
206 )
207 else:
208 # Create new record
209 record = FileIntegrityRecord(
210 file_path=normalized_path,
211 file_type=verifier.get_file_type(),
212 checksum=checksum,
213 algorithm=verifier.get_algorithm(),
214 file_size=file_stat.st_size,
215 file_mtime=file_stat.st_mtime,
216 verify_on_load=True,
217 allow_modifications=verifier.allows_modifications(),
218 related_entity_type=related_entity_type,
219 related_entity_id=related_entity_id,
220 total_verifications=0,
221 consecutive_successes=0,
222 consecutive_failures=0,
223 )
224 session.add(record)
225 logger.info(
226 f"[FILE_INTEGRITY] Created record for: {file_path} (type: {verifier.get_file_type()})"
227 )
228 except Exception as exc:
229 if not nested:
230 # Standalone: let get_user_db_session roll back the failed
231 # write (original behavior — nothing else is pending).
232 raise
233 # Nested: the SAVEPOINT already rolled back JUST this write; the
234 # caller's flushed rows are intact. We must NOT let the exception
235 # escape this block, or get_user_db_session's except would do a
236 # FULL session.rollback() and discard them. Exit cleanly, then
237 # re-raise below so apply()'s retry/failure logic still sees it.
238 nested_error = exc
240 if nested_error is None:
241 session.refresh(record)
243 if nested_error is not None:
244 raise nested_error
245 return record
247 def verify_file(
248 self, file_path: Path, force: bool = False, *, nested: bool = False
249 ) -> Tuple[bool, Optional[str]]:
250 """
251 Verify file integrity with smart checking.
253 Only verifies if:
254 - File modification time changed since last verification, OR
255 - force=True
257 Args:
258 file_path: Path to file to verify
259 force: Force verification even if file hasn't changed
261 Returns:
262 Tuple of (success, reason_if_failed)
263 """
264 normalized_path = self._normalize_path(file_path)
266 with self._session_for(nested) as session:
267 record = (
268 session.query(FileIntegrityRecord)
269 .filter_by(file_path=normalized_path)
270 .first()
271 )
273 if not record:
274 logger.error(
275 f"[FILE_INTEGRITY] No integrity record found for {file_path}, refusing to load"
276 )
277 return False, NO_INTEGRITY_RECORD
279 # Check if verification needed
280 if not force and not self._needs_verification(record, file_path):
281 logger.debug(
282 f"[FILE_INTEGRITY] Skipping verification for {file_path} (unchanged)"
283 )
284 return True, None
286 # Perform verification (the RESULT below is computed here and is
287 # independent of the stats bookkeeping write that follows).
288 passed, reason = self._do_verification(record, file_path, session)
290 try:
291 with self._integrity_write(session, nested):
292 # Update statistics
293 self._update_stats(record, passed, session)
295 # Log failure if needed
296 if not passed:
297 self._log_failure(
298 record,
299 file_path,
300 reason or "Unknown failure",
301 session,
302 )
303 except Exception:
304 if not nested:
305 raise
306 # Nested: the SAVEPOINT rolled back only this stats write; the
307 # caller's flushed rows are intact. Do NOT let the exception
308 # escape (get_user_db_session would FULL-rollback the caller's
309 # session). Stats bookkeeping is best-effort — the verification
310 # result is already decided, so log and return it.
311 logger.warning(
312 "[FILE_INTEGRITY] Could not persist verification stats for "
313 f"{file_path} (verification result unaffected)"
314 )
316 if passed:
317 logger.info(
318 f"[FILE_INTEGRITY] Verification passed: {file_path}"
319 )
320 else:
321 logger.error(
322 f"[FILE_INTEGRITY] Verification FAILED: {file_path} - {reason}"
323 )
325 return passed, reason
327 def update_checksum(self, file_path: Path) -> None:
328 """
329 Update checksum after legitimate file modification.
331 Use this when you know a file was legitimately modified
332 and want to update the baseline checksum.
334 Args:
335 file_path: Path to file
337 Raises:
338 FileNotFoundError: If file doesn't exist
339 ValueError: If no record exists for file
340 """
341 if not file_path.exists():
342 raise FileNotFoundError(f"File not found: {file_path}")
344 verifier = self._get_verifier_for_file(file_path)
345 if not verifier:
346 raise ValueError(f"No verifier registered for file: {file_path}")
348 checksum = verifier.calculate_checksum(file_path)
349 file_stat = file_path.stat()
351 with get_user_db_session(self.username, self.password) as session:
352 record = (
353 session.query(FileIntegrityRecord)
354 .filter_by(file_path=str(file_path))
355 .first()
356 )
358 if not record:
359 raise ValueError(f"No integrity record exists for: {file_path}")
361 record.checksum = checksum
362 record.file_size = file_stat.st_size
363 record.file_mtime = file_stat.st_mtime
364 record.updated_at = datetime.now(UTC)
366 session.commit()
367 logger.info(f"[FILE_INTEGRITY] Updated checksum for: {file_path}")
369 def get_file_stats(self, file_path: Path) -> Optional[dict]:
370 """
371 Get verification statistics for a file.
373 Args:
374 file_path: Path to file
376 Returns:
377 Dictionary of stats or None if no record exists
378 """
379 with get_user_db_session(self.username, self.password) as session:
380 record = (
381 session.query(FileIntegrityRecord)
382 .filter_by(file_path=str(file_path))
383 .first()
384 )
386 if not record:
387 return None
389 return {
390 "total_verifications": record.total_verifications,
391 "last_verified_at": record.last_verified_at,
392 "last_verification_passed": record.last_verification_passed,
393 "consecutive_successes": record.consecutive_successes,
394 "consecutive_failures": record.consecutive_failures,
395 "file_type": record.file_type,
396 "created_at": record.created_at,
397 }
399 def get_failure_history(
400 self, file_path: Path, limit: int = 100
401 ) -> List[FileVerificationFailure]:
402 """
403 Get failure history for a file.
405 Args:
406 file_path: Path to file
407 limit: Maximum number of failures to return
409 Returns:
410 List of failure records
411 """
412 with get_user_db_session(self.username, self.password) as session:
413 record = (
414 session.query(FileIntegrityRecord)
415 .filter_by(file_path=str(file_path))
416 .first()
417 )
419 if not record:
420 return []
422 failures = (
423 session.query(FileVerificationFailure)
424 .filter_by(file_record_id=record.id)
425 .order_by(FileVerificationFailure.verified_at.desc())
426 .limit(limit)
427 .all()
428 )
430 # Detach from session
431 for f in failures:
432 session.expunge(f)
434 return failures
436 # Internal methods
438 def _get_verifier_for_file(
439 self, file_path: Path
440 ) -> Optional[BaseFileVerifier]:
441 """Find verifier that handles this file type."""
442 for verifier in self.verifiers:
443 if verifier.should_verify(file_path):
444 return verifier
445 return None
447 def _needs_verification(
448 self, record: FileIntegrityRecord, file_path: Path
449 ) -> bool:
450 """
451 Check if file needs verification.
453 Only verify if file modification time changed since last verification.
454 """
455 if not file_path.exists():
456 return True # File missing needs verification
458 if not record.last_verified_at:
459 return True # Never verified
461 current_mtime = file_path.stat().st_mtime
463 # Compare with stored mtime
464 if record.file_mtime is None:
465 return True # No mtime stored
467 # Verify if file was modified (allow small floating point differences)
468 return abs(current_mtime - record.file_mtime) > 0.001
470 def _do_verification(
471 self, record: FileIntegrityRecord, file_path: Path, session
472 ) -> Tuple[bool, Optional[str]]:
473 """
474 Perform actual verification.
476 Returns:
477 Tuple of (success, reason_if_failed)
478 """
479 # Check file exists
480 if not file_path.exists():
481 return False, "file_missing"
483 # Get verifier
484 verifier = self._get_verifier_for_file(file_path)
485 if not verifier:
486 return False, "no_verifier"
488 # Calculate current checksum
489 try:
490 current_checksum = verifier.calculate_checksum(file_path)
491 except Exception as e:
492 logger.exception("[FILE_INTEGRITY] Failed to calculate checksum")
493 return False, f"checksum_calculation_failed: {str(e)}"
495 # Compare checksums
496 if current_checksum != record.checksum:
497 return False, "checksum_mismatch"
499 # Update file mtime in record
500 record.file_mtime = file_path.stat().st_mtime
502 return True, None
504 def _update_stats(
505 self, record: FileIntegrityRecord, passed: bool, session
506 ) -> None:
507 """Update verification statistics."""
508 record.total_verifications += 1
509 record.last_verified_at = datetime.now(UTC)
510 record.last_verification_passed = passed
512 if passed:
513 record.consecutive_successes += 1
514 record.consecutive_failures = 0
515 else:
516 record.consecutive_failures += 1
517 record.consecutive_successes = 0
519 def _log_failure(
520 self,
521 record: FileIntegrityRecord,
522 file_path: Path,
523 reason: str,
524 session,
525 ) -> None:
526 """Log verification failure to audit trail."""
527 # Get current checksum if possible
528 actual_checksum = None
529 file_size = None
531 if file_path.exists():
532 try:
533 verifier = self._get_verifier_for_file(file_path)
534 if verifier: 534 ↛ 544line 534 didn't jump to line 544 because the condition on line 534 was always true
535 actual_checksum = verifier.calculate_checksum(file_path)
536 file_size = file_path.stat().st_size
537 except Exception:
538 logger.debug(
539 "Checksum calculation failed for {}",
540 file_path,
541 exc_info=True,
542 )
544 failure = FileVerificationFailure(
545 file_record_id=record.id,
546 expected_checksum=record.checksum,
547 actual_checksum=actual_checksum,
548 file_size=file_size,
549 failure_reason=reason,
550 )
551 session.add(failure)
553 logger.warning(
554 f"[FILE_INTEGRITY] Logged failure for {file_path}: {reason}"
555 )
557 # Cleanup old failures for this file
558 self._cleanup_old_failures(record, session)
560 # Periodically check if global cleanup needed (every 100th file to avoid overhead)
561 if record.id % 100 == 0:
562 self._check_global_cleanup_needed(session)
564 def _cleanup_old_failures(
565 self, record: FileIntegrityRecord, session
566 ) -> None:
567 """
568 Clean up old failure records to prevent unbounded growth.
570 Keeps only the most recent MAX_FAILURES_PER_FILE failures per file.
571 """
572 # Count failures for this file
573 failure_count = (
574 session.query(FileVerificationFailure)
575 .filter_by(file_record_id=record.id)
576 .count()
577 )
579 if failure_count > self.MAX_FAILURES_PER_FILE:
580 # Delete oldest failures, keeping only the most recent MAX_FAILURES_PER_FILE
581 failures_to_delete = (
582 session.query(FileVerificationFailure)
583 .filter_by(file_record_id=record.id)
584 .order_by(FileVerificationFailure.verified_at.asc())
585 .limit(failure_count - self.MAX_FAILURES_PER_FILE)
586 .all()
587 )
589 for failure in failures_to_delete:
590 session.delete(failure)
592 logger.info(
593 f"[FILE_INTEGRITY] Cleaned up {len(failures_to_delete)} old failures for file_record {record.id}"
594 )
596 def _check_global_cleanup_needed(self, session) -> None:
597 """
598 Check if global cleanup is needed and run it if threshold exceeded.
600 Only runs cleanup if failure count exceeds MAX_TOTAL_FAILURES by 20%.
601 This prevents constant cleanup while allowing some buffer.
602 """
603 threshold = int(self.MAX_TOTAL_FAILURES * 1.2) # 20% over limit
604 total_failures = session.query(FileVerificationFailure).count()
606 if total_failures > threshold:
607 logger.info(
608 f"[FILE_INTEGRITY] Global failure count ({total_failures}) exceeds threshold ({threshold}), "
609 f"running cleanup..."
610 )
612 # Delete oldest failures to get under limit
613 failures_to_delete_count = total_failures - self.MAX_TOTAL_FAILURES
615 failures_to_delete = (
616 session.query(FileVerificationFailure)
617 .order_by(FileVerificationFailure.verified_at.asc())
618 .limit(failures_to_delete_count)
619 .all()
620 )
622 for failure in failures_to_delete:
623 session.delete(failure)
625 logger.info(
626 f"[FILE_INTEGRITY] Threshold cleanup: deleted {len(failures_to_delete)} old failures"
627 )
629 def cleanup_all_old_failures(self) -> int:
630 """
631 Global cleanup of failure records across all files.
633 Enforces MAX_TOTAL_FAILURES limit by removing oldest failures.
635 Returns:
636 Number of records deleted
637 """
638 with get_user_db_session(self.username, self.password) as session:
639 total_failures = session.query(FileVerificationFailure).count()
641 if total_failures <= self.MAX_TOTAL_FAILURES:
642 return 0
644 # Delete oldest failures to get under limit
645 failures_to_delete_count = total_failures - self.MAX_TOTAL_FAILURES
647 failures_to_delete = (
648 session.query(FileVerificationFailure)
649 .order_by(FileVerificationFailure.verified_at.asc())
650 .limit(failures_to_delete_count)
651 .all()
652 )
654 for failure in failures_to_delete:
655 session.delete(failure)
657 session.commit()
659 logger.info(
660 f"[FILE_INTEGRITY] Global cleanup: deleted {len(failures_to_delete)} old failures "
661 f"(total was {total_failures}, now {total_failures - len(failures_to_delete)})"
662 )
664 return len(failures_to_delete)
666 def get_total_failure_count(self) -> int:
667 """
668 Get total number of failure records across all files.
670 Returns:
671 Total count of failure records
672 """
673 with get_user_db_session(self.username, self.password) as session:
674 return session.query(FileVerificationFailure).count()