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

235 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +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 

35# for the same user across different BackupService instances 

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

37_user_locks_lock = threading.Lock() 

38 

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

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

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

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

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

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

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

46 

47 

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

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

50 

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

52 

53 Args: 

54 username: The username to get lock for 

55 

56 Returns: 

57 A threading.Lock for the specified user 

58 """ 

59 with _user_locks_lock: 

60 if username not in _user_locks: 

61 _user_locks[username] = threading.Lock() 

62 return _user_locks[username] 

63 

64 

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

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

67 

68 Rejects symlinks and paths that resolve outside the expected 

69 base directory, preventing path-traversal via crafted filenames 

70 or symlink attacks. 

71 

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

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

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

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

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

77 

78 Args: 

79 path: The path returned by glob to validate. 

80 base_dir: The directory the path must reside within. 

81 

82 Returns: 

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

84 to a location within base_dir. 

85 """ 

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

87 base_dir.resolve() 

88 ) 

89 

90 

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

92 """Remove the per-user backup lock for ``username`` from the registry. 

93 

94 Called from the user-close path so the module-level dict doesn't 

95 accumulate one entry per username across the process lifetime. The 

96 next backup operation lazily re-creates the lock if needed — the 

97 lock has no state that needs to persist across login/logout. 

98 """ 

99 with _user_locks_lock: 

100 _user_locks.pop(username, None) 

101 

102 

103@dataclass 

104class BackupResult: 

105 """Result of a backup operation.""" 

106 

107 success: bool 

108 backup_path: Optional[Path] = None 

109 error: Optional[str] = None 

110 size_bytes: int = 0 

111 

112 

113class BackupService: 

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

115 

116 Uses sqlcipher_export() for safe backups that: 

117 - Work correctly with WAL mode 

118 - Preserve encryption with the same key 

119 - Create atomic copies via ATTACH + export + DETACH 

120 - Never corrupt the source database 

121 """ 

122 

123 def __init__( 

124 self, 

125 username: str, 

126 password: str, 

127 max_backups: int = 1, 

128 max_age_days: int = 7, 

129 ): 

130 """Initialize backup service. 

131 

132 Args: 

133 username: User's username 

134 password: User's password (for encryption) 

135 max_backups: Maximum number of backup files to keep 

136 max_age_days: Delete backups older than this many days 

137 """ 

138 self.username = username 

139 self.password = password 

140 self.max_backups = max_backups 

141 self.max_age_days = max_age_days 

142 

143 # Get paths 

144 self.db_filename = get_user_database_filename(username) 

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

146 self.backup_dir = get_user_backup_directory(username) 

147 

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

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

150 

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

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

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

154 with cleanup operations. 

155 

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

157 a corrupted database from rapidly overwriting all good backups. 

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

159 

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

161 backup operations for the same user. 

162 

163 Args: 

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

165 

166 Returns: 

167 BackupResult with success status and backup path 

168 """ 

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

170 with _get_user_lock(self.username): 

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

172 if not force: 

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

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

175 existing_today = [ 

176 f 

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

178 if is_safe_glob_result(f, self.backup_dir) 

179 ] 

180 if existing_today: 

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

182 logger.debug( 

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

184 "skipping" 

185 ) 

186 return BackupResult( 

187 success=True, 

188 backup_path=latest, 

189 size_bytes=latest.stat().st_size 

190 if latest.exists() 

191 else 0, 

192 ) 

193 

194 start = time.perf_counter() 

195 result = self._create_backup_impl() 

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

197 size_info = ( 

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

199 if result.size_bytes 

200 else "unknown size" 

201 ) 

202 if elapsed_ms > 1000: 

203 logger.info( 

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

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

206 ) 

207 else: 

208 logger.debug( 

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

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

211 ) 

212 return result 

213 

214 def _create_backup_impl(self) -> BackupResult: 

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

216 if not self.db_path.exists(): 

217 return BackupResult( 

218 success=False, 

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

220 ) 

221 

222 # Check available disk space 

223 try: 

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

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

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

227 if free_space < db_size * 2: 

228 return BackupResult( 

229 success=False, 

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

231 ) 

232 except OSError as e: 

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

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

235 return BackupResult( 

236 success=False, 

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

238 ) 

239 

240 # Generate backup filename with timestamp 

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

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

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

244 backup_path = self.backup_dir / backup_filename 

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

246 

247 try: 

248 # Create connection to source database 

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

250 cursor = conn.cursor() 

251 

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

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

254 

255 try: 

256 # Use sqlcipher_export() to create an encrypted backup 

257 # VACUUM INTO doesn't preserve encryption in SQLCipher 

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

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

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

261 # contain a username — only the offending characters. 

262 temp_path_str = str(temp_path) 

263 bad_chars = _UNSAFE_BACKUP_PATH_CHARS & set(temp_path_str) 

264 if bad_chars: 

265 raise ValueError( 

266 "Backup path contains characters not allowed in a " 

267 "SQLCipher ATTACH statement: " 

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

269 ) 

270 

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

272 hex_key = get_key_from_password( 

273 self.password, db_path=self.db_path 

274 ).hex() 

275 

276 # Defensive: ensure hex_key is strictly hexadecimal 

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

278 c in "0123456789abcdef" for c in hex_key 

279 ): 

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

281 

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

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

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

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

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

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

288 cursor.execute( 

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

290 ) 

291 

292 try: 

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

294 # Note: PRAGMA statements do not support parameter binding 

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

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

297 settings = get_sqlcipher_settings() 

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

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

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

301 cursor.execute( 

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

303 ) 

304 cursor.execute( 

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

306 ) 

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

308 

309 # Export all data to the backup database 

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

311 finally: 

312 # Always detach to release the backup file handle 

313 try: 

314 cursor.execute("DETACH DATABASE backup") 

315 except Exception: 

316 logger.warning( 

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

318 ) 

319 finally: 

320 safe_close(cursor, "backup cursor") 

321 safe_close(conn, "backup connection") 

322 

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

324 if not self._verify_backup(temp_path): 

325 # Delete corrupted backup 

326 if temp_path.exists(): 

327 temp_path.unlink() 

328 return BackupResult( 

329 success=False, 

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

331 ) 

332 

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

334 # SECURITY: Backup files contain sensitive user data 

335 os.chmod(temp_path, 0o600) 

336 

337 # Get backup size before rename 

338 backup_size = temp_path.stat().st_size 

339 

340 # Atomic rename from .tmp to final .db 

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

342 temp_path.rename(backup_path) 

343 

344 logger.info( 

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

346 ) 

347 

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

349 self._cleanup_old_backups() 

350 

351 return BackupResult( 

352 success=True, 

353 backup_path=backup_path, 

354 size_bytes=backup_size, 

355 ) 

356 

357 except Exception as e: 

358 logger.exception("Backup creation failed") 

359 # Clean up any partial backup (temp file) 

360 if temp_path.exists(): 360 ↛ 361line 360 didn't jump to line 361 because the condition on line 360 was never true

361 try: 

362 temp_path.unlink() 

363 except OSError: 

364 pass 

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

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

367 try: 

368 backup_path.unlink() 

369 except OSError: 

370 pass 

371 return BackupResult( 

372 success=False, 

373 error=str(e), 

374 ) 

375 

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

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

378 

379 Args: 

380 backup_path: Path to the backup file 

381 

382 Returns: 

383 True if backup is valid, False otherwise 

384 """ 

385 if not backup_path.exists(): 

386 return False 

387 

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

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

390 return False 

391 

392 try: 

393 # Import SQLCipher module 

394 from ..sqlcipher_compat import get_sqlcipher_module 

395 

396 sqlcipher3 = get_sqlcipher_module() 

397 

398 # Open the backup with the same password 

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

400 cursor = conn.cursor() 

401 

402 try: 

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

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

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

406 apply_sqlcipher_pragmas(cursor, creation_mode=False) 

407 

408 # Run quick integrity check 

409 cursor.execute("PRAGMA quick_check") 

410 result = cursor.fetchone() 

411 

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

413 # Additional verification: try to read a table 

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

415 return True 

416 

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

418 return False 

419 

420 finally: 

421 safe_close(cursor, "backup cursor") 

422 safe_close(conn, "backup connection") 

423 

424 except Exception: 

425 logger.warning("Backup verification failed") 

426 return False 

427 

428 def _cleanup_old_backups(self) -> int: 

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

430 

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

432 

433 Returns: 

434 Number of backups deleted 

435 """ 

436 deleted_count = 0 

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

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

439 

440 try: 

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

442 for tmp_file in [ 

443 f 

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

445 if is_safe_glob_result(f, self.backup_dir) 

446 ]: 

447 try: 

448 mtime = datetime.fromtimestamp( 

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

450 ) 

451 if mtime < stale_tmp_cutoff: 

452 tmp_file.unlink() 

453 logger.info( 

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

455 ) 

456 except (OSError, FileNotFoundError): 

457 pass 

458 

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

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

461 try: 

462 return p.stat().st_mtime 

463 except FileNotFoundError: 

464 return 0.0 

465 

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

467 backups = [ 

468 p 

469 for p in sorted( 

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

471 key=_safe_mtime, 

472 reverse=True, 

473 ) 

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

475 ] 

476 

477 for i, backup in enumerate(backups): 

478 should_delete = False 

479 

480 # Delete if beyond max count 

481 if i >= self.max_backups: 

482 should_delete = True 

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

484 

485 # Delete if too old 

486 else: 

487 try: 

488 mtime = datetime.fromtimestamp( 

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

490 ) 

491 if mtime < cutoff_time: 

492 should_delete = True 

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

494 except FileNotFoundError: 

495 continue 

496 

497 if should_delete: 

498 try: 

499 backup.unlink() 

500 deleted_count += 1 

501 logger.debug( 

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

503 ) 

504 except OSError: 

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

506 

507 except Exception: 

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

509 

510 if deleted_count > 0: 

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

512 

513 return deleted_count 

514 

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

516 """List all backups for this user. 

517 

518 Returns: 

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

520 """ 

521 backups = [] 

522 

523 try: 

524 

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

526 try: 

527 return p.stat().st_mtime 

528 except FileNotFoundError: 

529 return 0.0 

530 

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

532 for backup_file in sorted( 

533 [ 

534 f 

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

536 if is_safe_glob_result(f, self.backup_dir) 

537 ], 

538 key=_safe_mtime_list, 

539 reverse=True, 

540 ): 

541 try: 

542 stat = backup_file.stat() 

543 except FileNotFoundError: 

544 continue 

545 backups.append( 

546 { 

547 "filename": backup_file.name, 

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

549 "size_bytes": stat.st_size, 

550 "created_at": datetime.fromtimestamp( 

551 stat.st_mtime, tz=UTC 

552 ).isoformat(), 

553 } 

554 ) 

555 except Exception: 

556 logger.exception("Error listing backups") 

557 

558 return backups 

559 

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

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

562 

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

564 new backup encrypted under the current password. Old backups 

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

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

567 old (potentially compromised) password. 

568 

569 Returns: 

570 BackupResult from the fresh backup creation 

571 """ 

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

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

574 # between the purge and the fresh backup creation. 

575 with _get_user_lock(self.username): 

576 # Delete all existing backup files 

577 for info in self.list_backups(): 

578 try: 

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

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

581 except OSError: 

582 logger.warning( 

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

584 ) 

585 

586 # Also clean up any stale .tmp files 

587 for tmp_file in [ 

588 f 

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

590 if is_safe_glob_result(f, self.backup_dir) 

591 ]: 

592 try: 

593 tmp_file.unlink() 

594 except OSError: 

595 logger.warning( 

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

597 ) 

598 

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

600 return self._create_backup_impl() 

601 

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

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

604 

605 Returns: 

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

607 """ 

608 try: 

609 

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

611 try: 

612 return p.stat().st_mtime 

613 except FileNotFoundError: 

614 return 0.0 

615 

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

617 backups = [ 

618 p 

619 for p in sorted( 

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

621 key=_safe_mtime_latest, 

622 reverse=True, 

623 ) 

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

625 ] 

626 return backups[0] if backups else None 

627 except Exception: 

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

629 return None