Coverage for src/local_deep_research/database/backup/backup_service.py: 90%

234 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +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 os 

8import shutil 

9import threading 

10import time 

11from dataclasses import dataclass 

12from datetime import UTC, datetime, timedelta 

13from pathlib import Path 

14from typing import Optional 

15 

16from loguru import logger 

17 

18from ...utilities.resource_utils import safe_close 

19 

20from ...config.paths import ( 

21 get_encrypted_database_path, 

22 get_user_backup_directory, 

23 get_user_database_filename, 

24) 

25from ..sqlcipher_utils import ( 

26 apply_sqlcipher_pragmas, 

27 create_sqlcipher_connection, 

28 get_key_from_password, 

29 get_sqlcipher_settings, 

30 set_sqlcipher_key, 

31 verify_sqlcipher_connection, 

32) 

33 

34# Module-level per-user locks to prevent concurrent backup operations for the 

35# same user across different BackupService instances. Entries deliberately 

36# retain stable identity for the process lifetime: removing a lock between a 

37# caller's lookup and acquire would let a later caller create a second lock and 

38# bypass the serialization. 

39_user_locks: dict[str, threading.Lock] = {} 

40_user_locks_lock = threading.Lock() 

41 

42# Characters that must never appear in a backup path. ATTACH DATABASE takes a 

43# string literal and cannot be parameterized in SQLite/SQLCipher, so the path is 

44# interpolated into SQL. A single quote IS allowed — it is escaped by doubling 

45# (per the SQLite literal grammar) so an apostrophe in the data-dir path (a 

46# home dir named O'Brien, say) doesn't break backups. Backslash, double-quote, 

47# NUL and control chars are still rejected (never valid in a real backup path). 

48_UNSAFE_BACKUP_PATH_CHARS = frozenset('"\\\0\n\r\t') 

49 

50 

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

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

53 

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

55 

56 Args: 

57 username: The username to get lock for 

58 

59 Returns: 

60 A threading.Lock for the specified user 

61 """ 

62 with _user_locks_lock: 

63 if username not in _user_locks: 

64 _user_locks[username] = threading.Lock() 

65 return _user_locks[username] 

66 

67 

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

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

70 

71 Rejects symlinks and paths that resolve outside the expected 

72 base directory, preventing path-traversal via crafted filenames 

73 or symlink attacks. 

74 

75 This is a best-effort pre-check, not atomic with the subsequent 

76 stat()/unlink()/open() on the path (a classic TOCTOU window). It is 

77 acceptable here because the backup directory is per-user and 

78 server-owned with 0o600 backups — an attacker who could swap a file 

79 for a symlink already has write access to that directory. 

80 

81 Args: 

82 path: The path returned by glob to validate. 

83 base_dir: The directory the path must reside within. 

84 

85 Returns: 

86 True if the path is a regular (non-symlink) file that resolves 

87 to a location within base_dir. 

88 """ 

89 return not path.is_symlink() and path.resolve().is_relative_to( 

90 base_dir.resolve() 

91 ) 

92 

93 

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

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

96 

97 User-close cleanup can race both a held lock and a caller that has looked 

98 up the lock but not acquired it yet. Removing the registry entry in either 

99 case permits a later backup operation to create a second lock and enter the 

100 same user's backup critical section concurrently. 

101 

102 Keep one small lock per distinct username for the process lifetime. The 

103 function remains as a no-op for existing user-close cleanup callers. 

104 """ 

105 del username 

106 

107 

108@dataclass 

109class BackupResult: 

110 """Result of a backup operation.""" 

111 

112 success: bool 

113 backup_path: Optional[Path] = None 

114 error: Optional[str] = None 

115 size_bytes: int = 0 

116 

117 

118class BackupService: 

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

120 

121 Uses sqlcipher_export() for safe backups that: 

122 - Work correctly with WAL mode 

123 - Preserve encryption with the same key 

124 - Create atomic copies via ATTACH + export + DETACH 

125 - Never corrupt the source database 

126 """ 

127 

128 def __init__( 

129 self, 

130 username: str, 

131 password: str, 

132 max_backups: int = 1, 

133 max_age_days: int = 7, 

134 ): 

135 """Initialize backup service. 

136 

137 Args: 

138 username: User's username 

139 password: User's password (for encryption) 

140 max_backups: Maximum number of backup files to keep 

141 max_age_days: Delete backups older than this many days 

142 """ 

143 self.username = username 

144 self.password = password 

145 self.max_backups = max_backups 

146 self.max_age_days = max_age_days 

147 

148 # Get paths 

149 self.db_filename = get_user_database_filename(username) 

150 self.db_path = get_encrypted_database_path() / self.db_filename 

151 self.backup_dir = get_user_backup_directory(username) 

152 

153 def create_backup(self, force: bool = False) -> BackupResult: 

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

155 

156 Uses sqlcipher_export() to create a safe, atomic backup that inherits 

157 the encryption key from the source database. The backup is created 

158 with a .tmp suffix and atomically renamed to prevent race conditions 

159 with cleanup operations. 

160 

161 By default, only one backup per calendar day is created to prevent 

162 a corrupted database from rapidly overwriting all good backups. 

163 Use force=True to bypass this check (used by pre-migration backups). 

164 

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

166 backup operations for the same user. 

167 

168 Args: 

169 force: If True, skip the daily limit check. 

170 

171 Returns: 

172 BackupResult with success status and backup path 

173 """ 

174 # Acquire per-user lock to prevent concurrent backup operations 

175 with _get_user_lock(self.username): 

176 # Skip if a backup already exists for today (unless forced) 

177 if not force: 

178 today = datetime.now(UTC).strftime("%Y%m%d") 

179 # After globbing, verify each result is a direct child of backup_dir 

180 existing_today = [ 

181 f 

182 for f in self.backup_dir.glob(f"ldr_backup_{today}_*.db") 

183 if is_safe_glob_result(f, self.backup_dir) 

184 ] 

185 if existing_today: 

186 latest = max(existing_today, key=lambda p: p.name) 

187 logger.debug( 

188 f"Backup already exists for today ({latest.name}), " 

189 "skipping" 

190 ) 

191 return BackupResult( 

192 success=True, 

193 backup_path=latest, 

194 size_bytes=latest.stat().st_size 

195 if latest.exists() 

196 else 0, 

197 ) 

198 

199 start = time.perf_counter() 

200 result = self._create_backup_impl() 

201 elapsed_ms = (time.perf_counter() - start) * 1000 

202 size_info = ( 

203 f"{result.size_bytes / (1024 * 1024):.1f}MB" 

204 if result.size_bytes 

205 else "unknown size" 

206 ) 

207 if elapsed_ms > 1000: 

208 logger.info( 

209 f"Backup for user {self.username} " 

210 f"({size_info}) took {elapsed_ms:.0f}ms" 

211 ) 

212 else: 

213 logger.debug( 

214 f"Backup for user {self.username} " 

215 f"({size_info}) took {elapsed_ms:.0f}ms" 

216 ) 

217 return result 

218 

219 def _create_backup_impl(self) -> BackupResult: 

220 """Internal implementation of backup creation (must be called with lock held).""" 

221 if not self.db_path.exists(): 

222 return BackupResult( 

223 success=False, 

224 error=f"Database not found: {self.db_path}", 

225 ) 

226 

227 # Check available disk space 

228 try: 

229 db_size = self.db_path.stat().st_size 

230 free_space = shutil.disk_usage(self.backup_dir).free 

231 # Require at least 2x the database size as free space 

232 if free_space < db_size * 2: 

233 return BackupResult( 

234 success=False, 

235 error=f"Insufficient disk space. Need {db_size * 2} bytes, have {free_space}", 

236 ) 

237 except OSError as e: 

238 # Fail closed - don't proceed with backup if we can't verify disk space 

239 logger.warning("Could not check disk space, skipping backup") 

240 return BackupResult( 

241 success=False, 

242 error=f"Could not verify disk space: {e}", 

243 ) 

244 

245 # Generate backup filename with timestamp 

246 # Use .tmp suffix during creation to prevent cleanup race conditions 

247 timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") 

248 backup_filename = f"ldr_backup_{timestamp}.db" 

249 backup_path = self.backup_dir / backup_filename 

250 temp_path = self.backup_dir / f"ldr_backup_{timestamp}.db.tmp" 

251 

252 try: 

253 # Create connection to source database 

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

255 cursor = conn.cursor() 

256 

257 # Set busy timeout so concurrent writers don't cause instant failure 

258 cursor.execute("PRAGMA busy_timeout = 10000") 

259 

260 try: 

261 # Use sqlcipher_export() to create an encrypted backup 

262 # VACUUM INTO doesn't preserve encryption in SQLCipher 

263 # Security: the ATTACH path below is interpolated (not 

264 # parameterizable). Single quotes are escaped (see below); any 

265 # other unsafe char is rejected. Don't log the full path — it can 

266 # contain a username — only the offending characters. 

267 temp_path_str = str(temp_path) 

268 bad_chars = _UNSAFE_BACKUP_PATH_CHARS & set(temp_path_str) 

269 if bad_chars: 

270 raise ValueError( 

271 "Backup path contains characters not allowed in a " 

272 "SQLCipher ATTACH statement: " 

273 + ", ".join(sorted(repr(c) for c in bad_chars)) 

274 ) 

275 

276 # Get the hex key for ATTACH (same key derivation as source) 

277 hex_key = get_key_from_password( 

278 self.password, db_path=self.db_path 

279 ).hex() 

280 

281 # Defensive: ensure hex_key is strictly hexadecimal 

282 if not hex_key or not all( 282 ↛ 285line 282 didn't jump to line 285 because the condition on line 282 was never true

283 c in "0123456789abcdef" for c in hex_key 

284 ): 

285 raise ValueError("Derived key is not valid hex") 

286 

287 # Attach backup database with encryption (using temp path). 

288 # ATTACH DATABASE can't be parameterized in SQLite/SQLCipher, so 

289 # the path is interpolated as a string literal — escape any 

290 # single quote by doubling it per the SQLite literal grammar 

291 # (all other unsafe chars were rejected above). 

292 attach_path = temp_path_str.replace("'", "''") 

293 cursor.execute( 

294 f"ATTACH DATABASE '{attach_path}' AS backup KEY \"x'{hex_key}'\"" 

295 ) 

296 

297 try: 

298 # Apply cipher settings to the backup database (must match source) 

299 # Note: PRAGMA statements do not support parameter binding 

300 # in SQLite — f-string is required. Values are validated 

301 # upstream by get_sqlcipher_settings() against allow-lists. 

302 settings = get_sqlcipher_settings() 

303 page_size = int(settings["page_size"]) 

304 kdf_iter = int(settings["kdf_iterations"]) 

305 hmac_alg = str(settings["hmac_algorithm"]) 

306 cursor.execute( 

307 f"PRAGMA backup.cipher_page_size = {page_size}" 

308 ) 

309 cursor.execute( 

310 f"PRAGMA backup.cipher_hmac_algorithm = {hmac_alg}" 

311 ) 

312 cursor.execute(f"PRAGMA backup.kdf_iter = {kdf_iter}") 

313 

314 # Export all data to the backup database 

315 cursor.execute("SELECT sqlcipher_export('backup')") 

316 finally: 

317 # Always detach to release the backup file handle 

318 try: 

319 cursor.execute("DETACH DATABASE backup") 

320 except Exception: 

321 logger.warning( 

322 "DETACH failed (connection will release on close)" 

323 ) 

324 finally: 

325 safe_close(cursor, "backup cursor") 

326 safe_close(conn, "backup connection") 

327 

328 # Verify the backup is valid (still using temp path) 

329 if not self._verify_backup(temp_path): 

330 # Delete corrupted backup 

331 if temp_path.exists(): 

332 temp_path.unlink() 

333 return BackupResult( 

334 success=False, 

335 error="Backup verification failed - backup was corrupted", 

336 ) 

337 

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

339 # SECURITY: Backup files contain sensitive user data 

340 os.chmod(temp_path, 0o600) 

341 

342 # Get backup size before rename 

343 backup_size = temp_path.stat().st_size 

344 

345 # Atomic rename from .tmp to final .db 

346 # This ensures cleanup won't see/delete partially created backups 

347 temp_path.rename(backup_path) 

348 

349 logger.info( 

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

351 ) 

352 

353 # Cleanup old backups (safe now - new backup is finalized) 

354 self._cleanup_old_backups() 

355 

356 return BackupResult( 

357 success=True, 

358 backup_path=backup_path, 

359 size_bytes=backup_size, 

360 ) 

361 

362 except Exception as e: 

363 logger.exception("Backup creation failed") 

364 # Clean up any partial backup (temp file) 

365 if temp_path.exists(): 

366 try: 

367 temp_path.unlink() 

368 except OSError: 

369 pass 

370 # Also clean up final path in case rename partially succeeded 

371 if backup_path.exists(): 371 ↛ 372line 371 didn't jump to line 372 because the condition on line 371 was never true

372 try: 

373 backup_path.unlink() 

374 except OSError: 

375 pass 

376 return BackupResult( 

377 success=False, 

378 error=str(e), 

379 ) 

380 

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

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

383 

384 Args: 

385 backup_path: Path to the backup file 

386 

387 Returns: 

388 True if backup is valid, False otherwise 

389 """ 

390 if not backup_path.exists(): 

391 return False 

392 

393 if backup_path.stat().st_size == 0: 

394 logger.warning("Backup file is empty (0 bytes)") 

395 return False 

396 

397 try: 

398 # Import SQLCipher module 

399 from ..sqlcipher_compat import get_sqlcipher_module 

400 

401 sqlcipher3 = get_sqlcipher_module() 

402 

403 # Open the backup with the same password 

404 conn = sqlcipher3.connect(str(backup_path)) 

405 cursor = conn.cursor() 

406 

407 try: 

408 # Set encryption key using the SOURCE database's salt 

409 # (backup was encrypted with the source DB's per-database salt) 

410 set_sqlcipher_key(cursor, self.password, db_path=self.db_path) 

411 apply_sqlcipher_pragmas(cursor, creation_mode=False) 

412 

413 # Run quick integrity check 

414 cursor.execute("PRAGMA quick_check") 

415 result = cursor.fetchone() 

416 

417 if result and result[0] == "ok": 

418 # Additional verification: try to read a table 

419 if verify_sqlcipher_connection(cursor): 419 ↛ 422line 419 didn't jump to line 422 because the condition on line 419 was always true

420 return True 

421 

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

423 return False 

424 

425 finally: 

426 safe_close(cursor, "backup cursor") 

427 safe_close(conn, "backup connection") 

428 

429 except Exception: 

430 logger.warning("Backup verification failed") 

431 return False 

432 

433 def _cleanup_old_backups(self) -> int: 

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

435 

436 Also cleans up stale .tmp files from interrupted backups. 

437 

438 Returns: 

439 Number of backups deleted 

440 """ 

441 deleted_count = 0 

442 cutoff_time = datetime.now(UTC) - timedelta(days=self.max_age_days) 

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

444 

445 try: 

446 # Clean up stale .tmp files from interrupted/crashed backups 

447 for tmp_file in [ 

448 f 

449 for f in self.backup_dir.glob("ldr_backup_*.db.tmp") 

450 if is_safe_glob_result(f, self.backup_dir) 

451 ]: 

452 try: 

453 mtime = datetime.fromtimestamp( 

454 tmp_file.stat().st_mtime, tz=UTC 

455 ) 

456 if mtime < stale_tmp_cutoff: 

457 tmp_file.unlink() 

458 logger.info( 

459 f"Cleaned up stale temp file: {tmp_file.name}" 

460 ) 

461 except (OSError, FileNotFoundError): 

462 pass 

463 

464 # Get all backup files sorted by modification time (newest first) 

465 def _safe_mtime(p: Path) -> float: 

466 try: 

467 return p.stat().st_mtime 

468 except FileNotFoundError: 

469 return 0.0 

470 

471 # After globbing, verify each result is a direct child of backup_dir 

472 backups = [ 

473 p 

474 for p in sorted( 

475 self.backup_dir.glob("ldr_backup_*.db"), 

476 key=_safe_mtime, 

477 reverse=True, 

478 ) 

479 if p.exists() and is_safe_glob_result(p, self.backup_dir) 

480 ] 

481 

482 for i, backup in enumerate(backups): 

483 should_delete = False 

484 

485 # Delete if beyond max count 

486 if i >= self.max_backups: 

487 should_delete = True 

488 reason = f"exceeds max count ({self.max_backups})" 

489 

490 # Delete if too old 

491 else: 

492 try: 

493 mtime = datetime.fromtimestamp( 

494 backup.stat().st_mtime, tz=UTC 

495 ) 

496 if mtime < cutoff_time: 

497 should_delete = True 

498 reason = f"older than {self.max_age_days} days" 

499 except FileNotFoundError: 

500 continue 

501 

502 if should_delete: 

503 try: 

504 backup.unlink() 

505 deleted_count += 1 

506 logger.debug( 

507 f"Deleted old backup {backup.name}: {reason}" 

508 ) 

509 except OSError: 

510 logger.warning(f"Could not delete backup {backup.name}") 

511 

512 except Exception: 

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

514 

515 if deleted_count > 0: 

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

517 

518 return deleted_count 

519 

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

521 """List all backups for this user. 

522 

523 Returns: 

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

525 """ 

526 backups = [] 

527 

528 try: 

529 

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

531 try: 

532 return p.stat().st_mtime 

533 except FileNotFoundError: 

534 return 0.0 

535 

536 # After globbing, verify each result is a direct child of backup_dir 

537 for backup_file in sorted( 

538 [ 

539 f 

540 for f in self.backup_dir.glob("ldr_backup_*.db") 

541 if is_safe_glob_result(f, self.backup_dir) 

542 ], 

543 key=_safe_mtime_list, 

544 reverse=True, 

545 ): 

546 try: 

547 stat = backup_file.stat() 

548 except FileNotFoundError: 

549 continue 

550 backups.append( 

551 { 

552 "filename": backup_file.name, 

553 "path": backup_file.name, # Only expose filename, not full server path 

554 "size_bytes": stat.st_size, 

555 "created_at": datetime.fromtimestamp( 

556 stat.st_mtime, tz=UTC 

557 ).isoformat(), 

558 } 

559 ) 

560 except Exception: 

561 logger.exception("Error listing backups") 

562 

563 return backups 

564 

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

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

567 

568 Used after a password change to replace old-key backups with a 

569 new backup encrypted under the current password. Old backups 

570 encrypted with a previous password are a security risk (NIST 

571 SP 800-57, OWASP A02) because they remain decryptable with the 

572 old (potentially compromised) password. 

573 

574 Returns: 

575 BackupResult from the fresh backup creation 

576 """ 

577 # Hold per-user lock for the entire purge+create operation to 

578 # prevent a concurrent backup from writing an old-key backup 

579 # between the purge and the fresh backup creation. 

580 with _get_user_lock(self.username): 

581 # Delete all existing backup files 

582 for info in self.list_backups(): 

583 try: 

584 (self.backup_dir / info["path"]).unlink() 

585 logger.debug(f"Purged old-key backup: {info['filename']}") 

586 except OSError: 

587 logger.warning( 

588 f"Could not delete backup {info['filename']}" 

589 ) 

590 

591 # Also clean up any stale .tmp files 

592 for tmp_file in [ 

593 f 

594 for f in self.backup_dir.glob("ldr_backup_*.db.tmp") 

595 if is_safe_glob_result(f, self.backup_dir) 

596 ]: 

597 try: 

598 tmp_file.unlink() 

599 except OSError: 

600 logger.warning( 

601 f"Could not delete stale tmp file {tmp_file.name}" 

602 ) 

603 

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

605 return self._create_backup_impl() 

606 

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

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

609 

610 Returns: 

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

612 """ 

613 try: 

614 

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

616 try: 

617 return p.stat().st_mtime 

618 except FileNotFoundError: 

619 return 0.0 

620 

621 # After globbing, verify each result is a direct child of backup_dir 

622 backups = [ 

623 p 

624 for p in sorted( 

625 self.backup_dir.glob("ldr_backup_*.db"), 

626 key=_safe_mtime_latest, 

627 reverse=True, 

628 ) 

629 if p.exists() and is_safe_glob_result(p, self.backup_dir) 

630 ] 

631 return backups[0] if backups else None 

632 except Exception: 

633 logger.exception("Error finding latest backup") 

634 return None