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

1"""Core backup service for encrypted database backups. 

2 

3Uses sqlcipher_export() for safe atomic backups that preserve encryption 

4and work correctly with WAL mode. 

5""" 

6 

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 

18 

19from loguru import logger 

20 

21from ...utilities.resource_utils import safe_close 

22 

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) 

41 

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() 

49 

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]] 

54 

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]]] = {} 

64 

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') 

76 

77 

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) 

83 

84 

85def _validate_backup_path(path: Path) -> str: 

86 """Normalize host separators and validate the path used in ATTACH. 

87 

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 

103 

104 

105def _get_user_lock(username: str) -> threading.Lock: 

106 """Get or create a lock for a specific user. 

107 

108 Thread-safe lazy initialization of per-user locks. 

109 

110 Args: 

111 username: The username to get lock for 

112 

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] 

120 

121 

122def _get_backup_verifications( 

123 username: str, 

124) -> dict[Path, tuple[_BackupIdentity, bool]]: 

125 """Return the user's remembered verification results. 

126 

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, {}) 

131 

132 

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 

137 

138 

139def _backup_identity(backup_path: Path) -> Optional[_BackupIdentity]: 

140 """Identify the exact database and salt files a verification reads. 

141 

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 

157 

158 

159def _is_published_pair( 

160 published: Optional[_BackupIdentity], verified: _BackupIdentity 

161) -> bool: 

162 """Whether the published files are the complete pair that was verified. 

163 

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] 

178 

179 

180def is_safe_glob_result(path: Path, base_dir: Path) -> bool: 

181 """Validate that a glob result is safe to use. 

182 

183 Rejects symlinks and paths that resolve outside the expected 

184 base directory, preventing path-traversal via crafted filenames 

185 or symlink attacks. 

186 

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. 

192 

193 Args: 

194 path: The path returned by glob to validate. 

195 base_dir: The directory the path must reside within. 

196 

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. 

201 

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 

209 

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 

218 

219 

220def pop_user_lock(username: str) -> None: 

221 """Compatibility no-op: per-user backup locks keep stable identity. 

222 

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. 

227 

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 

232 

233 

234@dataclass 

235class BackupResult: 

236 """Result of a backup operation.""" 

237 

238 success: bool 

239 backup_path: Optional[Path] = None 

240 error: Optional[str] = None 

241 size_bytes: int = 0 

242 

243 

244class BackupService: 

245 """Service for creating and managing encrypted database backups. 

246 

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 """ 

253 

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. 

262 

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 

276 

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) 

281 

282 def create_backup( 

283 self, force: bool = False, prune: bool = True 

284 ) -> BackupResult: 

285 """Create an encrypted backup of the user's database. 

286 

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. 

295 

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). 

304 

305 This method is protected by a per-user lock to prevent concurrent 

306 backup operations for the same user. 

307 

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. 

316 

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 ) 

343 

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 

363 

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 ) 

371 

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 ) 

389 

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 

396 

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) 

421 

422 # Create connection to source database 

423 conn = create_sqlcipher_connection(str(self.db_path), self.password) 

424 cursor = conn.cursor() 

425 

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) 

437 

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) 

446 

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() 

451 

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") 

457 

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 ) 

469 

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}") 

491 

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") 

505 

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 ) 

515 

516 # Set restrictive permissions (owner read/write only) 

517 # SECURITY: Backup files contain sensitive user data 

518 os.chmod(temp_path, 0o600) 

519 

520 # Get backup size before rename 

521 backup_size = temp_path.stat().st_size 

522 

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) 

531 

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) 

547 

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 ) 

562 

563 logger.info( 

564 f"Created backup for user: {backup_path.name} ({backup_size} bytes)" 

565 ) 

566 

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 ) 

580 

581 return BackupResult( 

582 success=True, 

583 backup_path=backup_path, 

584 size_bytes=backup_size, 

585 ) 

586 

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 ) 

605 

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. 

610 

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 

630 

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. 

635 

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] 

654 

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)) 

670 

671 def modified_ns(candidate: tuple[Path, _BackupIdentity]) -> int: 

672 return candidate[1][0][3] 

673 

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 

686 

687 def _copy_source_salt(self, temp_path: Path) -> bool: 

688 """Preserve the original salt, never generate a replacement. 

689 

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. 

693 

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 

718 

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) 

724 

725 def _verify_backup(self, backup_path: Path) -> bool: 

726 """Verify that a backup file is valid and readable. 

727 

728 Args: 

729 backup_path: Path to the backup file 

730 

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 

741 

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 

750 

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 

765 

766 try: 

767 # Import SQLCipher module 

768 from ..sqlcipher_compat import ( 

769 connect_sqlcipher, 

770 get_sqlcipher_module, 

771 ) 

772 

773 sqlcipher3 = get_sqlcipher_module() 

774 

775 # Open the backup with the same password 

776 conn = connect_sqlcipher(sqlcipher3, str(backup_path)) 

777 cursor = conn.cursor() 

778 

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) 

785 

786 # Run quick integrity check 

787 cursor.execute("PRAGMA quick_check") 

788 result = cursor.fetchone() 

789 

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 

794 

795 logger.warning(f"Backup integrity check failed: {result}") 

796 return False 

797 

798 finally: 

799 safe_close(cursor, "backup cursor") 

800 safe_close(conn, "backup connection") 

801 

802 except Exception: 

803 logger.warning("Backup verification failed") 

804 return False 

805 

806 def _age_cutoff(self) -> Optional[datetime]: 

807 """Return the time before which backups are too old, or None. 

808 

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 

832 

833 def _count_limit(self) -> Optional[float]: 

834 """Return how many backups to keep, or None for no count limit. 

835 

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 

864 

865 def _cleanup_old_backups(self, keep: Optional[Path] = None) -> int: 

866 """Remove old backups based on age and count limits. 

867 

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``). 

873 

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. 

880 

881 Returns: 

882 Number of backups deleted 

883 """ 

884 deleted_count = 0 

885 cutoff_time = self._age_cutoff() 

886 count_limit = self._count_limit() 

887 

888 try: 

889 stale_tmp_cutoff = datetime.now(UTC) - timedelta(hours=1) 

890 

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 

908 

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 

936 

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 

943 

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 ] 

954 

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 ) 

964 

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] 

970 

971 for i, backup in enumerate(backups): 

972 should_delete = False 

973 

974 if keep is not None and backup == keep: 

975 continue 

976 

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" 

981 

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 

993 

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}") 

1003 

1004 except Exception: 

1005 logger.exception("Error during backup cleanup") 

1006 

1007 if deleted_count > 0: 

1008 logger.info(f"Cleaned up {deleted_count} old backups") 

1009 

1010 return deleted_count 

1011 

1012 def list_backups(self) -> list[dict]: 

1013 """List all backups for this user. 

1014 

1015 Returns: 

1016 List of backup info dictionaries with path, size, and timestamp 

1017 """ 

1018 backups = [] 

1019 

1020 try: 

1021 

1022 def _safe_mtime_list(p: Path) -> float: 

1023 try: 

1024 return p.stat().st_mtime 

1025 except FileNotFoundError: 

1026 return 0.0 

1027 

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") 

1054 

1055 return backups 

1056 

1057 def _quarantined_backup_paths(self) -> list[Path]: 

1058 """Return contained damaged copies for key cleanup and retention. 

1059 

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 ] 

1071 

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() 

1077 

1078 def purge_and_refresh(self) -> "BackupResult": 

1079 """Delete all existing backups and create a fresh one. 

1080 

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. 

1086 

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() 

1097 

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 ) 

1107 

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 ) 

1120 

1121 # Create fresh backup with current password (lock already held) 

1122 return self._create_backup_impl() 

1123 

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 

1127 

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 

1140 

1141 def _classify_backup_key(self, backup_path: Path) -> str: 

1142 """Say whether a backup is under the current password's key. 

1143 

1144 Returns one of: 

1145 

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. 

1162 

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") 

1183 

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" 

1192 

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" 

1226 

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. 

1229 

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) 

1245 

1246 def _move_damaged_backup_aside(self, backup_path: Path) -> Optional[Path]: 

1247 """Rename a damaged current-key backup out of restore candidates. 

1248 

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 

1269 

1270 def purge_backups_under_superseded_key(self) -> int: 

1271 """Delete backups that are under a superseded password's key. 

1272 

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. 

1283 

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. 

1288 

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 

1335 

1336 def get_latest_backup(self) -> Optional[Path]: 

1337 """Get the path to the most recent backup. 

1338 

1339 Returns: 

1340 Path to latest backup, or None if no backups exist 

1341 """ 

1342 try: 

1343 

1344 def _safe_mtime_latest(p: Path) -> float: 

1345 try: 

1346 return p.stat().st_mtime 

1347 except FileNotFoundError: 

1348 return 0.0 

1349 

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