Coverage for src/local_deep_research/database/backup/backup_service.py: 90%
522 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""Core backup service for encrypted database backups.
3Uses sqlcipher_export() for safe atomic backups that preserve encryption
4and work correctly with WAL mode.
5"""
7import hmac
8import os
9import re
10import shutil
11import tempfile
12import threading
13import time
14from dataclasses import dataclass
15from datetime import UTC, datetime, timedelta
16from pathlib import Path
17from typing import Optional
19from loguru import logger
21from ...utilities.resource_utils import safe_close
23from ...config.paths import (
24 get_encrypted_database_path,
25 get_user_backup_directory,
26 get_user_database_filename,
27)
28from ..sqlcipher_utils import (
29 LEGACY_PBKDF2_SALT,
30 apply_performance_pragmas,
31 apply_sqlcipher_pragmas,
32 create_sqlcipher_connection,
33 get_key_from_password,
34 get_salt_file_path,
35 get_salt_for_database,
36 get_sqlcipher_settings,
37 has_per_database_salt,
38 set_sqlcipher_key,
39 verify_sqlcipher_connection,
40)
42# Module-level per-user locks to prevent concurrent backup operations for the
43# same user across different BackupService instances. Entries deliberately
44# retain stable identity for the process lifetime: removing a lock between a
45# caller's lookup and acquire would let a later caller create a second lock and
46# bypass the serialization.
47_user_locks: dict[str, threading.Lock] = {}
48_user_locks_lock = threading.Lock()
50# Device, inode, size and modification time of one file.
51_FileIdentity = tuple[int, int, int, int]
52# Identity of a backup database and of its salt file (None when it has none).
53_BackupIdentity = tuple[_FileIdentity, Optional[_FileIdentity]]
55# Full backup verifications run in this process, per user: {username: {backup
56# path: (identity of the database and salt files verified, whether they
57# passed)}}. _verify_backup costs a full key derivation and a PRAGMA
58# quick_check over the whole backup, so the daily limit reuses a remembered
59# pass, and skips a remembered failure, only while both files keep that
60# identity. A restart forgets every result; a password change forgets that
61# user's. A user's entries are read and written only while holding that user's
62# backup lock. They contain file metadata, no key material.
63_backup_verifications: dict[str, dict[Path, tuple[_BackupIdentity, bool]]] = {}
65# Characters that must never appear in the validated backup path string.
66# ATTACH DATABASE takes a string literal and cannot be parameterized in
67# SQLite/SQLCipher, so the path is interpolated into SQL. A single quote IS
68# allowed — it is escaped by doubling (per the SQLite literal grammar) so an
69# apostrophe in the data-dir path (a home dir named O'Brien, say) doesn't
70# break backups. Double-quote, NUL and control chars are rejected outright. A
71# backslash is in the set, but never appears in the validated string in a real
72# backup path: it is the Windows separator, normalised to "/" before this check
73# (see _validate_backup_path); on POSIX a real separator is always "/", so a
74# literal backslash there is conservatively rejected.
75_UNSAFE_BACKUP_PATH_CHARS = frozenset('"\\\0\n\r\t')
78# Only names produced by _move_damaged_backup_aside belong to the managed
79# quarantine. Salt files and unrelated operator files must not match.
80_QUARANTINED_BACKUP_NAME_RE = re.compile(
81 r"ldr_backup_.+\.db\.damaged-[0-9]{8}_[0-9]{6}_[0-9]{6}"
82)
85def _validate_backup_path(path: Path) -> str:
86 """Normalize host separators and validate the path used in ATTACH.
88 Use this before reserving files and again for the exported temporary path.
89 Filesystem operations keep their native Path; a literal POSIX backslash
90 remains invalid instead of being redirected to another directory.
91 """
92 path_string = str(path)
93 if os.sep == "\\":
94 path_string = path_string.replace("\\", "/")
95 bad_chars = _UNSAFE_BACKUP_PATH_CHARS & set(path_string)
96 if bad_chars:
97 raise ValueError(
98 "Backup path contains characters not allowed in a "
99 "SQLCipher ATTACH statement: "
100 + ", ".join(sorted(repr(c) for c in bad_chars))
101 )
102 return path_string
105def _get_user_lock(username: str) -> threading.Lock:
106 """Get or create a lock for a specific user.
108 Thread-safe lazy initialization of per-user locks.
110 Args:
111 username: The username to get lock for
113 Returns:
114 A threading.Lock for the specified user
115 """
116 with _user_locks_lock:
117 if username not in _user_locks:
118 _user_locks[username] = threading.Lock()
119 return _user_locks[username]
122def _get_backup_verifications(
123 username: str,
124) -> dict[Path, tuple[_BackupIdentity, bool]]:
125 """Return the user's remembered verification results.
127 Read or change the returned mapping only while holding the user's lock.
128 """
129 with _user_locks_lock:
130 return _backup_verifications.setdefault(username, {})
133def _file_identity(path: Path) -> _FileIdentity:
134 """Return the device, inode, size and mtime of ``path`` itself."""
135 status = path.lstat()
136 return status.st_dev, status.st_ino, status.st_size, status.st_mtime_ns
139def _backup_identity(backup_path: Path) -> Optional[_BackupIdentity]:
140 """Identify the exact database and salt files a verification reads.
142 ``lstat`` gives a replaced file, including one replaced by a symlink, a
143 different identity. A legacy backup has no salt file. None means the
144 files could not be identified, so no result for them is reused.
145 """
146 try:
147 database = _file_identity(backup_path)
148 except OSError:
149 return None
150 try:
151 salt = _file_identity(get_salt_file_path(backup_path))
152 except FileNotFoundError:
153 salt = None
154 except OSError:
155 return None
156 return database, salt
159def _is_published_pair(
160 published: Optional[_BackupIdentity], verified: _BackupIdentity
161) -> bool:
162 """Whether the published files are the complete pair that was verified.
164 The database, and the salt if one was verified, must be there with the
165 size they had when verified, and a salt must be absent if none was. A
166 rename keeps a file's size. Device, inode and modification time are not
167 compared: should a filesystem change them on rename, that only means a
168 later login verifies the backup again.
169 """
170 if published is None: 170 ↛ 171line 170 didn't jump to line 171 because the condition on line 170 was never true
171 return False
172 (database, salt), (verified_database, verified_salt) = published, verified
173 if database[2] != verified_database[2]:
174 return False
175 if salt is None or verified_salt is None:
176 return salt is verified_salt
177 return salt[2] == verified_salt[2]
180def is_safe_glob_result(path: Path, base_dir: Path) -> bool:
181 """Validate that a glob result is safe to use.
183 Rejects symlinks and paths that resolve outside the expected
184 base directory, preventing path-traversal via crafted filenames
185 or symlink attacks.
187 This is a best-effort pre-check, not atomic with the subsequent
188 stat()/unlink()/open() on the path (a classic TOCTOU window). It is
189 acceptable here because the backup directory is per-user and
190 server-owned with 0o600 backups — an attacker who could swap a file
191 for a symlink already has write access to that directory.
193 Args:
194 path: The path returned by glob to validate.
195 base_dir: The directory the path must reside within.
197 Both sides are resolved with ``resolve_allow_missing()``, never a bare
198 non-strict ``resolve()``, so a path that cannot be fully resolved (a
199 loop, a symlink chain whose resolved form outgrows PATH_MAX) is
200 rejected instead of being compared half resolved.
202 Returns:
203 True if the path is a regular (non-symlink) file that resolves
204 to a location within base_dir.
205 """
206 # Lazy import: keeps the security package out of this database
207 # module's import-time graph (the database package loads at bootstrap).
208 from ...security.path_validator import resolve_allow_missing
210 if path.is_symlink():
211 return False
212 try:
213 return resolve_allow_missing(path).is_relative_to(
214 resolve_allow_missing(base_dir)
215 )
216 except OSError:
217 return False
220def pop_user_lock(username: str) -> None:
221 """Compatibility no-op: per-user backup locks keep stable identity.
223 User-close cleanup can race both a held lock and a caller that has looked
224 up the lock but not acquired it yet. Removing the registry entry in either
225 case permits a later backup operation to create a second lock and enter the
226 same user's backup critical section concurrently.
228 Keep one small lock per distinct username for the process lifetime. The
229 function remains as a no-op for existing user-close cleanup callers.
230 """
231 del username
234@dataclass
235class BackupResult:
236 """Result of a backup operation."""
238 success: bool
239 backup_path: Optional[Path] = None
240 error: Optional[str] = None
241 size_bytes: int = 0
244class BackupService:
245 """Service for creating and managing encrypted database backups.
247 Uses sqlcipher_export() for safe backups that:
248 - Work correctly with WAL mode
249 - Preserve encryption with the same key
250 - Create atomic copies via ATTACH + export + DETACH
251 - Never corrupt the source database
252 """
254 def __init__(
255 self,
256 username: str,
257 password: str,
258 max_backups: int = 1,
259 max_age_days: int = 7,
260 ):
261 """Initialize backup service.
263 Args:
264 username: User's username
265 password: User's password (for encryption)
266 max_backups: Maximum number of backup files to keep. A value
267 below 1 is treated as 1 when backups are pruned.
268 max_age_days: Delete backups older than this many days. Zero,
269 a negative value, or a value too large to turn into a date
270 means no age limit.
271 """
272 self.username = username
273 self.password = password
274 self.max_backups = max_backups
275 self.max_age_days = max_age_days
277 # Get paths
278 self.db_filename = get_user_database_filename(username)
279 self.db_path = get_encrypted_database_path() / self.db_filename
280 self.backup_dir = get_user_backup_directory(username)
282 def create_backup(
283 self, force: bool = False, prune: bool = True
284 ) -> BackupResult:
285 """Create an encrypted backup of the user's database.
287 Uses sqlcipher_export() to create a safe, atomic backup that inherits
288 the encryption key from the source database. The matching salt is
289 copied alongside it. The database is published only after its salt,
290 so a completed .db file never depends on the source's salt file.
291 If either file is removed or changed while the copy is verified, or
292 a file of the pair is missing, or no longer the size verified, when
293 it is published, the new backup fails and is removed, and no older
294 backup is deleted.
296 By default, only one backup per calendar day is created to prevent
297 a corrupted database from rapidly overwriting all good backups.
298 Only a backup that passed the full verification counts. That runs
299 when a backup is created. An existing backup is verified again only
300 when none of today's backups has a pass this process remembers for
301 its current database and salt files, and only if no failure is
302 remembered for those files either (see _find_verified_backup).
303 Use force=True to bypass this check (used by pre-migration backups).
305 This method is protected by a per-user lock to prevent concurrent
306 backup operations for the same user.
308 Args:
309 force: If True, skip the daily limit check.
310 prune: If False, keep every existing backup instead of applying
311 this service's ``max_backups`` / ``max_age_days`` limits
312 after the new backup is written. Used by the pre-migration
313 backup when the user's own retention settings could not be
314 read or are out of range. With True, the new backup itself
315 is never deleted by this cleanup.
317 Returns:
318 BackupResult with success status and backup path
319 """
320 # Acquire per-user lock to prevent concurrent backup operations
321 with _get_user_lock(self.username):
322 # Skip if a backup already exists for today (unless forced)
323 if not force:
324 today = datetime.now(UTC).strftime("%Y%m%d")
325 # After globbing, verify each result is a direct child of backup_dir
326 todays_backups = [
327 f
328 for f in self.backup_dir.glob(f"ldr_backup_{today}_*.db")
329 if is_safe_glob_result(f, self.backup_dir)
330 ]
331 existing = self._find_verified_backup(todays_backups)
332 if existing is not None:
333 existing_path, existing_size = existing
334 logger.debug(
335 "Backup already exists for today "
336 f"({existing_path.name}), skipping"
337 )
338 return BackupResult(
339 success=True,
340 backup_path=existing_path,
341 size_bytes=existing_size,
342 )
344 start = time.perf_counter()
345 result = self._create_backup_impl(prune=prune)
346 elapsed_ms = (time.perf_counter() - start) * 1000
347 size_info = (
348 f"{result.size_bytes / (1024 * 1024):.1f}MB"
349 if result.size_bytes
350 else "unknown size"
351 )
352 if elapsed_ms > 1000:
353 logger.info(
354 f"Backup for user {self.username} "
355 f"({size_info}) took {elapsed_ms:.0f}ms"
356 )
357 else:
358 logger.debug(
359 f"Backup for user {self.username} "
360 f"({size_info}) took {elapsed_ms:.0f}ms"
361 )
362 return result
364 def _create_backup_impl(self, prune: bool = True) -> BackupResult:
365 """Internal implementation of backup creation (must be called with lock held)."""
366 if not self.db_path.exists():
367 return BackupResult(
368 success=False,
369 error=f"Database not found: {self.db_path}",
370 )
372 # Check available disk space
373 try:
374 db_size = self.db_path.stat().st_size
375 free_space = shutil.disk_usage(self.backup_dir).free
376 # Require at least 2x the database size as free space
377 if free_space < db_size * 2:
378 return BackupResult(
379 success=False,
380 error=f"Insufficient disk space. Need {db_size * 2} bytes, have {free_space}",
381 )
382 except OSError as e:
383 # Fail closed - don't proceed with backup if we can't verify disk space
384 logger.warning("Could not check disk space, skipping backup")
385 return BackupResult(
386 success=False,
387 error=f"Could not verify disk space: {e}",
388 )
390 # Reserve a unique, owner-only temporary file. Forced backups can run
391 # within the same second; neither a new export nor failed cleanup may
392 # overwrite a previous successful backup.
393 timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
394 temp_path = None
395 backup_path = None
397 try:
398 _validate_backup_path(self.backup_dir)
399 fd, temp_name = (
400 tempfile.mkstemp( # Safe: empty owner-only file; SQLCipher writes ciphertext
401 prefix=f"ldr_backup_{timestamp}_",
402 suffix=".db.tmp",
403 dir=self.backup_dir,
404 )
405 )
406 os.close(fd)
407 temp_path = Path(temp_name)
408 destination = temp_path.with_suffix("")
409 destination_salt = get_salt_file_path(destination)
410 if (
411 destination.exists()
412 or destination.is_symlink()
413 or destination_salt.exists()
414 or destination_salt.is_symlink()
415 ):
416 raise FileExistsError( # noqa: TRY301 - outer handler removes our reserved temp file
417 "Backup destination already exists"
418 )
419 backup_path = destination
420 salt_copied = self._copy_source_salt(temp_path)
422 # Create connection to source database
423 conn = create_sqlcipher_connection(str(self.db_path), self.password)
424 cursor = conn.cursor()
426 # Honour the operator-configured ``db_config.busy_timeout_ms``
427 # (default 30 s) instead of the previous hardcoded 10 s.
428 # The source DB already had this applied on its own
429 # connection; this backup connection is a separate one and
430 # the SQLCipher `creator` only sets the cipher defaults
431 # before the key -- a backup racing the background document
432 # scheduler's PDF-download transaction used to die at
433 # 10 s while the source DB's writer rode out 30 s, leaving
434 # the backup silently failing for no operator-tunable
435 # reason.
436 apply_performance_pragmas(cursor)
438 try:
439 # Use sqlcipher_export() to create an encrypted backup
440 # VACUUM INTO doesn't preserve encryption in SQLCipher
441 # Security: the ATTACH path below is interpolated (not
442 # parameterizable). Single quotes are escaped (see below); any
443 # other unsafe chars were rejected before file creation.
444 # Use the same normalization for the reserved temporary path.
445 temp_path_str = _validate_backup_path(temp_path)
447 # Get the hex key for ATTACH (same key derivation as source)
448 hex_key = get_key_from_password(
449 self.password, db_path=temp_path
450 ).hex()
452 # Defensive: ensure hex_key is strictly hexadecimal
453 if not hex_key or not all( 453 ↛ 456line 453 didn't jump to line 456 because the condition on line 453 was never true
454 c in "0123456789abcdef" for c in hex_key
455 ):
456 raise ValueError("Derived key is not valid hex")
458 # Attach backup database with encryption (using temp path).
459 # ATTACH DATABASE can't be parameterized in SQLite/SQLCipher, so
460 # the path is interpolated as a string literal — escape any
461 # single quote by doubling it per the SQLite literal grammar
462 # (all other unsafe chars were rejected above).
463 attach_path = temp_path_str.replace("'", "''")
464 # nosemgrep: semgrep.rules.sql-execute-with-string-format, reason: ATTACH cannot be bound; unsafe path chars rejected; quotes doubled; key checked as hex
465 cursor.execute(
466 # nosemgrep: semgrep.rules.sql-string-concatenation, reason: ATTACH path rejects unsafe characters and doubles quotes; the derived key is validated as ASCII hex
467 f"ATTACH DATABASE '{attach_path}' AS backup KEY \"x'{hex_key}'\""
468 )
470 try:
471 # Apply cipher settings to the backup database (must match source)
472 # Note: PRAGMA statements do not support parameter binding
473 # in SQLite — f-string is required. Values are validated
474 # upstream by get_sqlcipher_settings() against allow-lists.
475 settings = get_sqlcipher_settings()
476 page_size = int(settings["page_size"])
477 kdf_iter = int(settings["kdf_iterations"])
478 hmac_alg = str(settings["hmac_algorithm"])
479 # nosemgrep: semgrep.rules.sql-execute-with-string-format, reason: PRAGMA cannot be bound; value coerced with int() above
480 cursor.execute(
481 # nosemgrep: semgrep.rules.sql-string-concatenation, reason: PRAGMA page size is converted to int before interpolation
482 f"PRAGMA backup.cipher_page_size = {page_size}"
483 )
484 # nosemgrep: semgrep.rules.sql-execute-with-string-format, reason: PRAGMA cannot be bound; hmac_algorithm is an EnumSetting allow-list value
485 cursor.execute(
486 # nosemgrep: semgrep.rules.sql-string-concatenation, reason: PRAGMA HMAC algorithm comes from the EnumSetting allowlist
487 f"PRAGMA backup.cipher_hmac_algorithm = {hmac_alg}"
488 )
489 # nosemgrep: semgrep.rules.sql-execute-with-string-format, semgrep.rules.sql-string-concatenation, reason: PRAGMA cannot be bound; value coerced with int() above
490 cursor.execute(f"PRAGMA backup.kdf_iter = {kdf_iter}")
492 # Export all data to the backup database
493 cursor.execute("SELECT sqlcipher_export('backup')")
494 finally:
495 # Always detach to release the backup file handle
496 try:
497 cursor.execute("DETACH DATABASE backup")
498 except Exception:
499 logger.warning(
500 "DETACH failed (connection will release on close)"
501 )
502 finally:
503 safe_close(cursor, "backup cursor")
504 safe_close(conn, "backup connection")
506 # Verify the backup is valid (still using temp path)
507 passed, verified_identity = self._verify_and_identify(temp_path)
508 if not passed or verified_identity is None:
509 # Delete corrupted backup
510 self._remove_backup_files(temp_path)
511 return BackupResult(
512 success=False,
513 error="Backup verification failed - backup was corrupted",
514 )
516 # Set restrictive permissions (owner read/write only)
517 # SECURITY: Backup files contain sensitive user data
518 os.chmod(temp_path, 0o600)
520 # Get backup size before rename
521 backup_size = temp_path.stat().st_size
523 # Renaming keeps both identities, so later logins today reuse
524 # this verification while the published files stay unchanged.
525 # Record it before publishing: an error after the renames would
526 # make the handler below delete the new backup. If publication
527 # fails, the entry matches no file.
528 _get_backup_verifications(self.username)[
529 self.backup_dir / backup_path.name
530 ] = (verified_identity, True)
532 # Publish the salt first, then the database as the completion
533 # marker. A crash between renames leaves only an orphan salt,
534 # which retention cleanup can remove after the stale-file delay.
535 # The salt rename does not depend on the salt still existing:
536 # if it has gone since the verification, the rename raises and
537 # the handler below removes the partial pair before any older
538 # backup is deleted.
539 if salt_copied:
540 # nosemgrep: semgrep.rules.path-traversal-risk, reason: The source is the fixed .salt sidecar of this call's mkstemp file in the server-owned sha256-named per-user backup directory
541 get_salt_file_path(temp_path).rename(
542 # nosemgrep: semgrep.rules.path-traversal-risk, reason: The target is the fixed .salt sidecar of the same mkstemp name without .tmp in that directory
543 get_salt_file_path(backup_path)
544 )
545 # nosemgrep: semgrep.rules.path-traversal-risk, reason: temp_path is this call's mkstemp file in the server-owned sha256-named per-user backup directory and backup_path is that name without .tmp
546 temp_path.rename(backup_path)
548 # Delete older backups only while the complete pair that was
549 # verified is in place under its final names.
550 if not _is_published_pair(
551 _backup_identity(backup_path), verified_identity
552 ):
553 logger.warning(
554 f"Backup {backup_path.name} changed during "
555 "publication; removing it"
556 )
557 self._remove_backup_files(backup_path)
558 return BackupResult(
559 success=False,
560 error="Backup files changed during publication",
561 )
563 logger.info(
564 f"Created backup for user: {backup_path.name} ({backup_size} bytes)"
565 )
567 # Cleanup old backups (safe now - new backup is finalized).
568 # The new backup is passed as ``keep`` so no retention value
569 # can delete it. A cleanup failure must not reach the except
570 # below, which would delete the backup that was just finalized.
571 if prune:
572 try:
573 self._cleanup_old_backups(keep=backup_path)
574 except Exception as cleanup_error:
575 logger.warning(
576 "Old-backup cleanup failed "
577 f"({type(cleanup_error).__name__}); keeping the new "
578 "backup"
579 )
581 return BackupResult(
582 success=True,
583 backup_path=backup_path,
584 size_bytes=backup_size,
585 )
587 except Exception as e:
588 logger.exception("Backup creation failed")
589 # Clean up any partial backup (temp file)
590 if temp_path is not None:
591 try:
592 self._remove_backup_files(temp_path)
593 except OSError:
594 pass
595 # Also clean up final path in case rename partially succeeded
596 if backup_path is not None:
597 try:
598 self._remove_backup_files(backup_path)
599 except OSError:
600 pass
601 return BackupResult(
602 success=False,
603 error=str(e),
604 )
606 def _verify_and_identify(
607 self, backup_path: Path
608 ) -> tuple[bool, Optional[_BackupIdentity]]:
609 """Run the full verification and identify the files it covered.
611 A backup passes only if its database file, and its salt file or the
612 absence of one, are the same before and after the verification: a
613 file that is removed, replaced or written to during the check fails
614 it, whatever the check found. Opening a path that was removed during
615 the check makes SQLite create an empty database there, which the
616 checks accept, and a salt removed after the key was derived would
617 otherwise let the database be published without it. An empty
618 database never passes. The identity is returned only for files that
619 were the same before and after, and is None otherwise, so a result
620 is never remembered for files it did not read.
621 """
622 identity = _backup_identity(backup_path)
623 passed = self._verify_backup(backup_path)
624 after = _backup_identity(backup_path)
625 if after is None or after != identity:
626 return False, None
627 if after[0][2] == 0:
628 passed = False
629 return passed, identity
631 def _find_verified_backup(
632 self, backups: list[Path]
633 ) -> Optional[tuple[Path, int]]:
634 """Return one of ``backups`` that passes the full verification.
636 Returns that backup and its size, or None when none passes. Results
637 remembered in this process come first and cost no key derivation: a
638 pass is reused, and a failure skipped, while the backup's database
639 and salt files keep the device, inode, size and modification time
640 they had when verified. If any backup has a remembered pass, the
641 newest of those is returned and nothing is verified. Otherwise the
642 backups without a remembered result, such as after a restart or once
643 either file is replaced, removed or written to, are verified newest
644 first until one passes, and each result is remembered. A change that
645 preserves all four values is not detected. A backup that has been
646 removed counts as absent, and one whose database or salt file is
647 removed or changed during its verification does not pass, with no
648 result remembered. Must be called with the user's backup lock held.
649 """
650 results = _get_backup_verifications(self.username)
651 # Forget results for files that are not candidates any more.
652 for path in results.keys() - set(backups):
653 del results[path]
655 passes: list[tuple[Path, _BackupIdentity]] = []
656 unknown: list[tuple[Path, _BackupIdentity]] = []
657 for path in backups:
658 identity = _backup_identity(path)
659 remembered = results.get(path)
660 if identity is None: 660 ↛ 663line 660 didn't jump to line 663 because the condition on line 660 was never true
661 # Removed since the glob, or its files cannot be identified:
662 # there is no backup to count.
663 results.pop(path, None)
664 elif remembered is not None and remembered[0] == identity:
665 if remembered[1]:
666 passes.append((path, identity))
667 else:
668 results.pop(path, None)
669 unknown.append((path, identity))
671 def modified_ns(candidate: tuple[Path, _BackupIdentity]) -> int:
672 return candidate[1][0][3]
674 if passes:
675 newest, newest_identity = max(passes, key=modified_ns)
676 return newest, newest_identity[0][2]
677 for candidate, scanned in sorted(
678 unknown, key=modified_ns, reverse=True
679 ):
680 passed, verified = self._verify_and_identify(candidate)
681 if verified is not None:
682 results[candidate] = (verified, passed)
683 if passed:
684 return candidate, (verified or scanned)[0][2]
685 return None
687 def _copy_source_salt(self, temp_path: Path) -> bool:
688 """Preserve the original salt, never generate a replacement.
690 Legacy databases use a fixed salt built into the key derivation and
691 need no sidecar. Opening the source still verifies that this is
692 actually a legacy database, rather than a database with a lost salt.
694 Returns:
695 True if a salt file was written for ``temp_path``, False for a
696 legacy database.
697 """
698 source_salt = get_salt_file_path(self.db_path)
699 if source_salt.is_symlink(): 699 ↛ 700line 699 didn't jump to line 700 because the condition on line 699 was never true
700 raise ValueError("Database salt must not be a symlink")
701 salt = get_salt_for_database(self.db_path)
702 if salt == LEGACY_PBKDF2_SALT:
703 return False
704 fd = os.open(
705 get_salt_file_path(temp_path),
706 os.O_WRONLY | os.O_CREAT | os.O_EXCL,
707 0o600,
708 )
709 # The "wb" wrapper (io.FileIO) sets the descriptor to binary mode on
710 # Windows, so no salt byte is translated as a line ending.
711 with os.fdopen(
712 fd, "wb"
713 ) as salt_file: # Safe: non-secret salt for encrypted backup recovery
714 salt_file.write(salt)
715 salt_file.flush()
716 os.fsync(salt_file.fileno())
717 return True
719 @staticmethod
720 def _remove_backup_files(backup_path: Path) -> None:
721 """Remove a database and its salt without following salt symlinks."""
722 backup_path.unlink(missing_ok=True)
723 get_salt_file_path(backup_path).unlink(missing_ok=True)
725 def _verify_backup(self, backup_path: Path) -> bool:
726 """Verify that a backup file is valid and readable.
728 Args:
729 backup_path: Path to the backup file
731 Returns:
732 True if backup is valid, False otherwise
733 """
734 backup_salt = get_salt_file_path(backup_path)
735 if (
736 not backup_path.is_file()
737 or backup_path.is_symlink()
738 or backup_salt.is_symlink()
739 ):
740 return False
742 try:
743 backup_size = backup_path.stat().st_size
744 except OSError:
745 # Removed since the check above: there is no backup to verify.
746 return False
747 if backup_size == 0:
748 logger.warning("Backup file is empty (0 bytes)")
749 return False
751 # _copy_source_salt gives every new backup of a database that has its
752 # own salt a copy of it, and the application never adds a salt to a
753 # legacy database. A backup without a salt, of a database with one,
754 # is therefore incomplete (made before salts were copied, or damaged)
755 # or belongs to an earlier database: reject it before any key
756 # derivation, and before the legacy-salt warning that deriving its
757 # key would log. Only the presence of the source's salt is checked,
758 # so it can make this check stricter, never weaker.
759 if not backup_salt.exists() and has_per_database_salt(self.db_path):
760 logger.warning(
761 f"Backup {backup_path.name} has no salt file, but its "
762 "database has one"
763 )
764 return False
766 try:
767 # Import SQLCipher module
768 from ..sqlcipher_compat import (
769 connect_sqlcipher,
770 get_sqlcipher_module,
771 )
773 sqlcipher3 = get_sqlcipher_module()
775 # Open the backup with the same password
776 conn = connect_sqlcipher(sqlcipher3, str(backup_path))
777 cursor = conn.cursor()
779 try:
780 # Verify using only recovery inputs next to the backup.
781 # Never fall back to the source's salt: doing so would hide
782 # an incomplete backup until the original data is lost.
783 set_sqlcipher_key(cursor, self.password, db_path=backup_path)
784 apply_sqlcipher_pragmas(cursor, creation_mode=False)
786 # Run quick integrity check
787 cursor.execute("PRAGMA quick_check")
788 result = cursor.fetchone()
790 if result and result[0] == "ok":
791 # Additional verification: try to read a table
792 if verify_sqlcipher_connection(cursor): 792 ↛ 795line 792 didn't jump to line 795 because the condition on line 792 was always true
793 return True
795 logger.warning(f"Backup integrity check failed: {result}")
796 return False
798 finally:
799 safe_close(cursor, "backup cursor")
800 safe_close(conn, "backup connection")
802 except Exception:
803 logger.warning("Backup verification failed")
804 return False
806 def _age_cutoff(self) -> Optional[datetime]:
807 """Return the time before which backups are too old, or None.
809 None means no age limit. That is the case when ``max_age_days``
810 is zero or negative (``LDR_BACKUP_MAX_AGE_DAYS=0`` is a common way
811 to say "keep forever", and would otherwise put the cutoff at or
812 after now, so every backup would count as too old), and when it
813 cannot be turned into a date: an env override such as
814 ``LDR_BACKUP_MAX_AGE_DAYS=1000000`` or ``inf`` overflows the
815 subtraction, and ``nan`` or a non-number cannot be converted. The
816 count limit and stale ``.tmp`` removal still run.
817 """
818 try:
819 if self.max_age_days <= 0:
820 logger.info(
821 f"backup.max_age_days={self.max_age_days!r} is not "
822 "positive; not removing backups by age"
823 )
824 return None
825 return datetime.now(UTC) - timedelta(days=self.max_age_days)
826 except (OverflowError, ValueError, TypeError):
827 logger.warning(
828 f"backup.max_age_days={self.max_age_days!r} is out of "
829 "range; not removing backups by age"
830 )
831 return None
833 def _count_limit(self) -> Optional[float]:
834 """Return how many backups to keep, or None for no count limit.
836 A ``max_backups`` below 1 (``LDR_BACKUP_MAX_COUNT=0`` or a negative
837 value) is treated as 1, the settings' minimum: the newest backup is
838 always kept and older ones are still pruned, so the setting cannot
839 delete every backup and cannot silently stop limiting disk use.
840 A value that cannot be compared with a count (``nan`` or a
841 non-number) means no count limit, as with ``_age_cutoff``; ``inf``
842 also means no count limit.
843 """
844 limit = self.max_backups
845 try:
846 below_one = limit < 1
847 is_nan = limit != limit # only NaN is unequal to itself
848 except TypeError:
849 is_nan = True
850 else:
851 if below_one:
852 logger.warning(
853 f"backup.max_count={limit!r} is below 1; keeping only "
854 "the newest backup"
855 )
856 return 1
857 if is_nan:
858 logger.warning(
859 f"backup.max_count={limit!r} is not a usable number; not "
860 "removing backups by count"
861 )
862 return None
863 return limit
865 def _cleanup_old_backups(self, keep: Optional[Path] = None) -> int:
866 """Remove old backups based on age and count limits.
868 Also cleans up stale .tmp files from interrupted backups. A
869 ``max_age_days`` that is not positive or is out of range disables
870 only the age limit (see ``_age_cutoff``); the count limit still
871 applies. A ``max_backups`` below 1 is treated as 1 (see
872 ``_count_limit``).
874 Args:
875 keep: A backup that is never deleted and always takes the
876 first slot of the count limit. ``_create_backup_impl``
877 passes the backup it has just finalized, so cleanup can
878 never delete it, whatever the retention values or file
879 modification times are.
881 Returns:
882 Number of backups deleted
883 """
884 deleted_count = 0
885 cutoff_time = self._age_cutoff()
886 count_limit = self._count_limit()
888 try:
889 stale_tmp_cutoff = datetime.now(UTC) - timedelta(hours=1)
891 # Clean up stale .tmp files from interrupted/crashed backups
892 for tmp_file in [
893 f
894 for f in self.backup_dir.glob("ldr_backup_*.db.tmp")
895 if is_safe_glob_result(f, self.backup_dir)
896 ]:
897 try:
898 mtime = datetime.fromtimestamp(
899 tmp_file.stat().st_mtime, tz=UTC
900 )
901 if mtime < stale_tmp_cutoff:
902 self._remove_backup_files(tmp_file)
903 logger.info(
904 f"Cleaned up stale temp file: {tmp_file.name}"
905 )
906 except (OSError, FileNotFoundError):
907 pass
909 # Interrupted publication can leave a salt without a database.
910 # Keep recent files alone; they may belong to an active export.
911 for pattern in (
912 "ldr_backup_*.db.salt",
913 "ldr_backup_*.db.tmp.salt",
914 "ldr_backup_*.db.damaged-*.salt",
915 ):
916 for salt_file in self.backup_dir.glob(pattern):
917 if not is_safe_glob_result(salt_file, self.backup_dir): 917 ↛ 918line 917 didn't jump to line 918 because the condition on line 917 was never true
918 continue
919 if pattern == "ldr_backup_*.db.damaged-*.salt" and not (
920 _QUARANTINED_BACKUP_NAME_RE.fullmatch(
921 salt_file.with_suffix("").name
922 )
923 ):
924 continue
925 try:
926 if (
927 not salt_file.with_suffix("").exists()
928 and datetime.fromtimestamp(
929 salt_file.stat().st_mtime, tz=UTC
930 )
931 < stale_tmp_cutoff
932 ):
933 salt_file.unlink()
934 except OSError:
935 pass
937 # Get all backup files sorted by modification time (newest first)
938 def _safe_mtime(p: Path) -> float:
939 try:
940 return p.stat().st_mtime
941 except FileNotFoundError:
942 return 0.0
944 # After globbing, verify each result is a direct child of backup_dir
945 backups = [
946 p
947 for p in sorted(
948 self.backup_dir.glob("ldr_backup_*.db"),
949 key=_safe_mtime,
950 reverse=True,
951 )
952 if p.exists() and is_safe_glob_result(p, self.backup_dir)
953 ]
955 # Damaged copies share the retention budget, but must never
956 # displace a usable restore candidate just because they are newer.
957 backups.extend(
958 sorted(
959 self._quarantined_backup_paths(),
960 key=_safe_mtime,
961 reverse=True,
962 )
963 )
965 # The backup to keep goes first, so it fills the first slot
966 # of the count limit even if another file has a later mtime.
967 if keep is not None:
968 kept = [p for p in backups if p == keep]
969 backups = kept + [p for p in backups if p != keep]
971 for i, backup in enumerate(backups):
972 should_delete = False
974 if keep is not None and backup == keep:
975 continue
977 # Delete if beyond max count
978 if count_limit is not None and i >= count_limit:
979 should_delete = True
980 reason = "exceeds max count"
982 # Delete if too old (skipped when there is no age limit)
983 elif cutoff_time is not None:
984 try:
985 mtime = datetime.fromtimestamp(
986 backup.stat().st_mtime, tz=UTC
987 )
988 if mtime < cutoff_time:
989 should_delete = True
990 reason = f"older than {self.max_age_days} days"
991 except FileNotFoundError:
992 continue
994 if should_delete:
995 try:
996 self._remove_backup_files(backup)
997 deleted_count += 1
998 logger.debug(
999 f"Deleted old backup {backup.name}: {reason}"
1000 )
1001 except OSError:
1002 logger.warning(f"Could not delete backup {backup.name}")
1004 except Exception:
1005 logger.exception("Error during backup cleanup")
1007 if deleted_count > 0:
1008 logger.info(f"Cleaned up {deleted_count} old backups")
1010 return deleted_count
1012 def list_backups(self) -> list[dict]:
1013 """List all backups for this user.
1015 Returns:
1016 List of backup info dictionaries with path, size, and timestamp
1017 """
1018 backups = []
1020 try:
1022 def _safe_mtime_list(p: Path) -> float:
1023 try:
1024 return p.stat().st_mtime
1025 except FileNotFoundError:
1026 return 0.0
1028 # After globbing, verify each result is a direct child of backup_dir
1029 for backup_file in sorted(
1030 [
1031 f
1032 for f in self.backup_dir.glob("ldr_backup_*.db")
1033 if is_safe_glob_result(f, self.backup_dir)
1034 ],
1035 key=_safe_mtime_list,
1036 reverse=True,
1037 ):
1038 try:
1039 stat = backup_file.stat()
1040 except FileNotFoundError:
1041 continue
1042 backups.append(
1043 {
1044 "filename": backup_file.name,
1045 "path": backup_file.name, # Only expose filename, not full server path
1046 "size_bytes": stat.st_size,
1047 "created_at": datetime.fromtimestamp(
1048 stat.st_mtime, tz=UTC
1049 ).isoformat(),
1050 }
1051 )
1052 except Exception:
1053 logger.exception("Error listing backups")
1055 return backups
1057 def _quarantined_backup_paths(self) -> list[Path]:
1058 """Return contained damaged copies for key cleanup and retention.
1060 These files remain encrypted, but are not usable restore candidates
1061 and must stay out of list_backups(), get_latest_backup() and daily
1062 backup verification. They still belong to the password lifecycle.
1063 """
1064 return [
1065 path
1066 for path in self.backup_dir.glob("ldr_backup_*.db.damaged-*")
1067 if _QUARANTINED_BACKUP_NAME_RE.fullmatch(path.name)
1068 and is_safe_glob_result(path, self.backup_dir)
1069 and path.is_file()
1070 ]
1072 def _managed_backup_paths(self) -> list[Path]:
1073 """Include quarantined copies without exposing them for restore."""
1074 return [
1075 self.backup_dir / info["path"] for info in self.list_backups()
1076 ] + self._quarantined_backup_paths()
1078 def purge_and_refresh(self) -> "BackupResult":
1079 """Delete all existing backups and create a fresh one.
1081 Used after a password change to replace old-key backups with a
1082 new backup encrypted under the current password. Old backups
1083 encrypted with a previous password are a security risk (NIST
1084 SP 800-57, OWASP A02) because they remain decryptable with the
1085 old (potentially compromised) password.
1087 Returns:
1088 BackupResult from the fresh backup creation
1089 """
1090 # Hold per-user lock for the entire purge+create operation to
1091 # prevent a concurrent backup from writing an old-key backup
1092 # between the purge and the fresh backup creation.
1093 with _get_user_lock(self.username):
1094 # Forget results obtained with the old password, so a backup the
1095 # purge cannot delete is checked again with the new one.
1096 _get_backup_verifications(self.username).clear()
1098 # Delete all existing backup files
1099 for backup_path in self._managed_backup_paths():
1100 try:
1101 self._remove_backup_files(backup_path)
1102 logger.debug(f"Purged old-key backup: {backup_path.name}")
1103 except OSError:
1104 logger.warning(
1105 f"Could not delete backup {backup_path.name}"
1106 )
1108 # Also clean up any stale .tmp files
1109 for tmp_file in [
1110 f
1111 for f in self.backup_dir.glob("ldr_backup_*.db.tmp")
1112 if is_safe_glob_result(f, self.backup_dir)
1113 ]:
1114 try:
1115 self._remove_backup_files(tmp_file)
1116 except OSError:
1117 logger.warning(
1118 f"Could not delete stale tmp file {tmp_file.name}"
1119 )
1121 # Create fresh backup with current password (lock already held)
1122 return self._create_backup_impl()
1124 def _open_backup_with_current_key(self, backup_path: Path):
1125 """Return a raw connection to ``backup_path`` keyed with this password."""
1126 from ..sqlcipher_compat import connect_sqlcipher, get_sqlcipher_module
1128 conn = connect_sqlcipher(get_sqlcipher_module(), str(backup_path))
1129 try:
1130 cursor = conn.cursor()
1131 try:
1132 set_sqlcipher_key(cursor, self.password, db_path=backup_path)
1133 apply_sqlcipher_pragmas(cursor, creation_mode=False)
1134 finally:
1135 safe_close(cursor, "backup cursor")
1136 except Exception:
1137 safe_close(conn, "backup connection")
1138 raise
1139 return conn
1141 def _classify_backup_key(self, backup_path: Path) -> str:
1142 """Say whether a backup is under the current password's key.
1144 Returns one of:
1146 ``"current"``
1147 The current password decrypts its schema page. Kept.
1148 ``"superseded"``
1149 SQLite answers "file is not a database" and SQLCipher's page
1150 authentication fails for EVERY page of the file: no page was
1151 written under this key, so the backup is under another key,
1152 which for this user's own backup directory is a superseded
1153 password. Deleted.
1154 ``"damaged"``
1155 The schema page fails but other pages authenticate under the
1156 current key: a backup under the CURRENT key whose first page
1157 is damaged. Kept for salvage, subject to retention and later
1158 password changes like the other encrypted backup copies.
1159 ``"unknown"``
1160 Anything else (a lock, an I/O error, an unexpected size or
1161 scan result). Kept; nothing proves it superseded.
1163 The old password is not known where this runs (a login resolving
1164 a pending change has only the password that opened the database),
1165 so "under another key" is proven from the current key's side.
1166 Only SQLite's fixed messages are inspected; they never carry key
1167 material.
1168 """
1169 try:
1170 conn = self._open_backup_with_current_key(backup_path)
1171 except Exception:
1172 return "unknown"
1173 try:
1174 try:
1175 conn.execute("SELECT count(*) FROM sqlite_master").fetchone()
1176 except Exception as error:
1177 if "not a database" not in str(error):
1178 return "unknown"
1179 else:
1180 return "current"
1181 finally:
1182 safe_close(conn, "backup connection")
1184 # The verdicts below are about the KEY, and the key is derived from
1185 # the password AND the backup's salt. A rekey never changes a
1186 # database's salt (only creation writes it), so a backup under a
1187 # superseded password shares the live database's salt. A backup
1188 # whose salt differs (missing, damaged, from another database)
1189 # failed for a reason restoring the salt may fix: keep it.
1190 if not self._backup_salt_matches_live_database(backup_path):
1191 return "unknown"
1193 page_size = get_sqlcipher_settings()["page_size"]
1194 try:
1195 size = backup_path.stat().st_size
1196 except OSError:
1197 return "unknown"
1198 if size == 0 or size % page_size: 1198 ↛ 1199line 1198 didn't jump to line 1199 because the condition on line 1198 was never true
1199 return "unknown"
1200 page_count = size // page_size
1201 try:
1202 conn = self._open_backup_with_current_key(backup_path)
1203 except Exception:
1204 return "unknown"
1205 try:
1206 rows = conn.execute("PRAGMA cipher_integrity_check").fetchall()
1207 except Exception:
1208 return "unknown"
1209 finally:
1210 safe_close(conn, "backup connection")
1211 failed_pages = set()
1212 prefix = "HMAC verification failed for page "
1213 for row in rows:
1214 text = str(row[0]) if row else ""
1215 if not text.startswith(prefix): 1215 ↛ 1216line 1215 didn't jump to line 1216 because the condition on line 1215 was never true
1216 return "unknown"
1217 try:
1218 failed_pages.add(int(text[len(prefix) :]))
1219 except ValueError:
1220 return "unknown"
1221 if 1 not in failed_pages: 1221 ↛ 1222line 1221 didn't jump to line 1222 because the condition on line 1221 was never true
1222 return "unknown"
1223 if failed_pages >= set(range(1, page_count + 1)):
1224 return "superseded"
1225 return "damaged"
1227 def _backup_salt_matches_live_database(self, backup_path: Path) -> bool:
1228 """True when the backup derives its key from the live database's salt.
1230 Both sides are read the way key derivation reads them
1231 (``get_salt_for_database``: the legacy salt when no ``.salt`` file
1232 exists, Windows CR/LF damage undone). A backup without a salt file
1233 while the live database has one, a salt that cannot be read, or one
1234 with different bytes does not match.
1235 """
1236 try:
1237 live_has_salt = has_per_database_salt(self.db_path)
1238 if live_has_salt and not get_salt_file_path(backup_path).exists():
1239 return False
1240 live_salt = get_salt_for_database(self.db_path)
1241 backup_salt = get_salt_for_database(backup_path)
1242 except Exception:
1243 return False
1244 return hmac.compare_digest(live_salt, backup_salt)
1246 def _move_damaged_backup_aside(self, backup_path: Path) -> Optional[Path]:
1247 """Rename a damaged current-key backup out of restore candidates.
1249 The new name ends in ``.damaged-<UTC timestamp>`` (its salt keeps
1250 the ``.salt`` suffix after that). Internal key cleanup and retention
1251 still include this copy; ordinary listing and restore selection do
1252 not. Mode 0600. Returns the new path, or None when the move failed.
1253 """
1254 stamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S_%f")
1255 target = backup_path.with_name(f"{backup_path.name}.damaged-{stamp}")
1256 salt = get_salt_file_path(backup_path)
1257 try:
1258 if target.exists(): 1258 ↛ 1259line 1258 didn't jump to line 1259 because the condition on line 1258 was never true
1259 return None
1260 os.replace(backup_path, target)
1261 os.chmod(target, 0o600)
1262 if salt.exists() and not salt.is_symlink(): 1262 ↛ 1268line 1262 didn't jump to line 1268 because the condition on line 1262 was always true
1263 salt_target = target.with_name(f"{target.name}.salt")
1264 os.replace(salt, salt_target)
1265 os.chmod(salt_target, 0o600)
1266 except OSError:
1267 return None
1268 return target
1270 def purge_backups_under_superseded_key(self) -> int:
1271 """Delete backups that are under a superseded password's key.
1273 Used after a pending password change is resolved on login: if the
1274 rekey took effect, every earlier backup is still encrypted under
1275 the superseded (possibly compromised) password and is deleted, as
1276 ``purge_and_refresh`` does after a completed change. If the old
1277 key survived, every backup still opens and nothing is deleted.
1278 A backup proven to be under the current key but with a damaged
1279 first page is moved aside instead (see ``_classify_backup_key``);
1280 one whose key cannot be determined is kept. A backup without the
1281 salt its database needs is left to the regular verification,
1282 which already rejects it.
1284 Application callers must hold this database's rekey sidecar lock
1285 and establish that this password still belongs to its current
1286 stable epoch, through the entire purge. The per-user backup lock
1287 below does not exclude a password change.
1289 Returns:
1290 The number of backups deleted.
1291 """
1292 deleted = 0
1293 with _get_user_lock(self.username):
1294 _get_backup_verifications(self.username).clear()
1295 source_has_salt = has_per_database_salt(self.db_path)
1296 for backup_path in self._managed_backup_paths():
1297 backup_salt = get_salt_file_path(backup_path)
1298 if (
1299 not backup_path.is_file()
1300 or backup_path.is_symlink()
1301 or backup_salt.is_symlink()
1302 or (source_has_salt and not backup_salt.exists())
1303 ):
1304 continue
1305 verdict = self._classify_backup_key(backup_path)
1306 if verdict == "damaged":
1307 if _QUARANTINED_BACKUP_NAME_RE.fullmatch(backup_path.name):
1308 continue
1309 moved = self._move_damaged_backup_aside(backup_path)
1310 if moved is None: 1310 ↛ 1311line 1310 didn't jump to line 1311 because the condition on line 1310 was never true
1311 logger.warning(
1312 f"Backup {backup_path.name} is under the current "
1313 "key but damaged, and could not be moved aside"
1314 )
1315 else:
1316 logger.warning(
1317 f"Backup {backup_path.name} is under the current "
1318 f"key but its first page is damaged; moved aside "
1319 f"as {moved.name}"
1320 )
1321 continue
1322 if verdict != "superseded":
1323 continue
1324 try:
1325 self._remove_backup_files(backup_path)
1326 deleted += 1
1327 logger.info(
1328 f"Purged backup under superseded key: {backup_path.name}"
1329 )
1330 except OSError:
1331 logger.warning(
1332 f"Could not delete backup {backup_path.name}"
1333 )
1334 return deleted
1336 def get_latest_backup(self) -> Optional[Path]:
1337 """Get the path to the most recent backup.
1339 Returns:
1340 Path to latest backup, or None if no backups exist
1341 """
1342 try:
1344 def _safe_mtime_latest(p: Path) -> float:
1345 try:
1346 return p.stat().st_mtime
1347 except FileNotFoundError:
1348 return 0.0
1350 # After globbing, verify each result is a direct child of backup_dir
1351 backups = [
1352 p
1353 for p in sorted(
1354 self.backup_dir.glob("ldr_backup_*.db"),
1355 key=_safe_mtime_latest,
1356 reverse=True,
1357 )
1358 if p.exists() and is_safe_glob_result(p, self.backup_dir)
1359 ]
1360 return backups[0] if backups else None
1361 except Exception:
1362 logger.exception("Error finding latest backup")
1363 return None