Coverage for src/local_deep_research/database/rekey_lock.py: 91%

465 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1"""Cross-process serialization for SQLCipher database rekeys.""" 

2 

3from __future__ import annotations 

4 

5import errno 

6import os 

7import stat 

8import tempfile 

9import threading 

10import time 

11from collections.abc import Generator 

12from contextlib import contextmanager 

13from dataclasses import dataclass 

14from enum import StrEnum, unique 

15from pathlib import Path 

16from typing import assert_never 

17from uuid import UUID, uuid4 

18 

19 

20class RekeyOperationalError(Exception): 

21 """Base for explicit operational failures during password rekey.""" 

22 

23 

24@unique 

25class AuthorizationLeaseMode(StrEnum): 

26 """Cross-process authorization lease compatibility modes.""" 

27 

28 SHARED = "shared" 

29 EXCLUSIVE = "exclusive" 

30 

31 

32@unique 

33class RekeySidecarOperation(StrEnum): 

34 """Safe lifecycle stages for sidecar I/O diagnostics.""" 

35 

36 OPEN = "open" 

37 ACQUIRE = "acquire" 

38 UNLOCK = "unlock" 

39 CLOSE = "close" 

40 # Writing an epoch token (``.rekey.epoch`` / ``.rekey.pending``) while 

41 # the sidecar is held: mkstemp, write+fsync, replace, directory sync. 

42 PUBLISH = "publish" 

43 

44 

45# Errnos this module raises itself when it refuses a planted sidecar. 

46_SIDECAR_REFUSAL_DETAIL: dict[int | None, str] = { 

47 errno.ELOOP: "; refusing a symlinked sidecar", 

48 errno.EINVAL: "; refusing a non-regular sidecar", 

49 # st_nlink == 0 (an already-unlinked inode), or a hard link to a 

50 # database file or any other file in the sidecar's directory 

51 errno.EMLINK: "; refusing an unlinked sidecar or one hard-linked within its directory", 

52} 

53 

54 

55class RekeySidecarIOError(RekeyOperationalError): 

56 """Normalized sidecar I/O failure without raw operating-system text.""" 

57 

58 db_path: Path 

59 operation: RekeySidecarOperation 

60 errno: int | None 

61 

62 def __init__( 

63 self, 

64 db_path: Path, 

65 operation: RekeySidecarOperation, 

66 error_number: int | None, 

67 ) -> None: 

68 self.db_path = db_path 

69 self.operation = operation 

70 self.errno = error_number 

71 errno_text = "unknown" if error_number is None else str(error_number) 

72 detail = _SIDECAR_REFUSAL_DETAIL.get(error_number, "") 

73 super().__init__( 

74 f"rekey sidecar {operation.value} failed " 

75 f"(errno={errno_text}{detail})" 

76 ) 

77 

78 

79@unique 

80class RekeyStage(StrEnum): 

81 """Closed stages used to classify password-rekey outcomes.""" 

82 

83 PREPARATION = "preparation" 

84 PENDING_PUBLICATION = "pending-publication" 

85 PHYSICAL_REKEY = "physical-rekey" 

86 CANDIDATE_VERIFICATION = "candidate-verification" 

87 COMMIT_PUBLICATION = "commit-publication" 

88 

89 

90class RekeyUnavailableError(RekeyOperationalError): 

91 """Raised when rekey cannot proceed before its outcome becomes ambiguous.""" 

92 

93 db_path: Path 

94 stage: RekeyStage 

95 

96 def __init__(self, db_path: Path, stage: RekeyStage) -> None: 

97 self.db_path = db_path 

98 self.stage = stage 

99 super().__init__( 

100 f"rekey unavailable during {stage.value} for {db_path.name}" 

101 ) 

102 

103 

104class RekeyIndeterminateError(RekeyOperationalError): 

105 """Raised when durable state cannot prove which password remains active.""" 

106 

107 db_path: Path 

108 stage: RekeyStage 

109 state: RekeyEpochState | None 

110 

111 def __init__( 

112 self, 

113 db_path: Path, 

114 stage: RekeyStage, 

115 state: RekeyEpochState | None, 

116 ) -> None: 

117 self.db_path = db_path 

118 self.stage = stage 

119 self.state = state 

120 super().__init__( 

121 f"rekey outcome indeterminate during {stage.value} for {db_path.name}" 

122 ) 

123 

124 

125class RekeyLockContentionError(RekeyOperationalError): 

126 """Raised when another password rekey already owns the database lock.""" 

127 

128 lock_path: Path 

129 

130 def __init__(self, lock_path: Path) -> None: 

131 self.lock_path = lock_path 

132 super().__init__(f"rekey already in progress for {lock_path.name}") 

133 

134 

135class DatabaseKeyCheckUnavailableError(RekeyOperationalError): 

136 """The password could not be checked against the database right now. 

137 

138 Raised when opening the database to verify a password fails for an 

139 operational reason (SQLite reports the database locked or busy past the 

140 busy timeout, an I/O error, a file it cannot open), as opposed to the 

141 key being wrong ("file is not a database"). It says nothing about the 

142 password, so a login answers 503 without a lockout strike and a password 

143 change answers with its retryable failure, never "password incorrect". 

144 Only the SQLite exception type is kept, not its message. 

145 """ 

146 

147 db_path: Path 

148 reason: str 

149 

150 def __init__(self, db_path: Path, reason: str) -> None: 

151 self.db_path = db_path 

152 self.reason = reason 

153 super().__init__( 

154 f"password check unavailable for {db_path.name} ({reason})" 

155 ) 

156 

157 

158class RekeyRecoveryRequiredError(RekeyOperationalError): 

159 """Raised when durable rekey metadata requires verified recovery.""" 

160 

161 db_path: Path 

162 

163 def __init__(self, db_path: Path) -> None: 

164 self.db_path = db_path 

165 super().__init__(f"rekey recovery required for {db_path.name}") 

166 

167 

168class RekeyEpochUnreadableError(RekeyRecoveryRequiredError): 

169 """An epoch file exists (or may) but the read itself failed. 

170 

171 Raised for every ``OSError`` other than ``FileNotFoundError`` while 

172 reading ``.rekey.epoch`` / ``.rekey.pending`` (EMFILE, ENFILE, EAGAIN, 

173 EIO, EACCES, ...; EINTR never gets here, Python retries it itself per 

174 PEP 475), and when the reader refuses a planted file without reading 

175 it: a symlink (ELOOP) or anything that is not a regular file, such as 

176 a FIFO or a directory (EINVAL). It is a ``RekeyRecoveryRequiredError``, so 

177 every existing handler keeps failing closed exactly as before; the 

178 subclass only lets a caller that can retry later (session validation) 

179 tell "nothing could be read" apart from durable state that was read 

180 and does not vouch for the identity. ``errno`` is kept for 

181 diagnostics; the raw OS message is not. 

182 """ 

183 

184 errno: int | None 

185 

186 def __init__(self, db_path: Path, error_number: int | None) -> None: 

187 super().__init__(db_path) 

188 self.errno = error_number 

189 

190 

191@dataclass(frozen=True, slots=True) 

192class RekeyEpochState: 

193 """Durable committed and in-flight rekey generations.""" 

194 

195 committed_epoch: str | None 

196 pending_epoch: str | None 

197 

198 

199@dataclass(frozen=True, slots=True) 

200class _SidecarLockRequest: 

201 db_path: Path 

202 lock_path: Path 

203 mode: AuthorizationLeaseMode 

204 blocking: bool 

205 gate_wait: float | None = None 

206 contention_retry: float = 0.0 

207 # time.monotonic() value bounding the gate wait and the flock retries 

208 # together; see acquire_rekey_lock's ``timeout``. 

209 deadline: float | None = None 

210 

211 

212# Poll interval while a non-blocking caller rides out a brief foreign hold. 

213_CONTENTION_RETRY_INTERVAL = 0.01 

214 

215 

216# In-process gate per sidecar file. flock semantics make two fds in one 

217# process contend like unrelated processes, so a heavyweight sidecar user 

218# (change_password) cannot tell "an in-process holder has the lock" from 

219# "a cross-process rekey holds it" and either fails fast on both or blocks 

220# on both. In-process holders are not all brief: cache probes hold it for 

221# milliseconds, but a cold open holds it across migrations and engine 

222# build, and a disposal across its WAL checkpoint (seconds on a large 

223# database). Every in-process flock holder therefore takes this gate 

224# first: heavyweight users wait on the gate (bounded) for local holders, 

225# then take the flock non-blocking (with a short retry for other 

226# processes' probes) so genuinely foreign holders still fail with the 

227# typed contention error. Request-path callers bound the gate wait and 

228# the flock retries together, by one deadline (``timeout``). 

229_sidecar_gates: dict[Path, threading.Lock] = {} 

230_sidecar_gates_guard = threading.Lock() 

231 

232 

233def _get_sidecar_gate(lock_path: Path) -> threading.Lock: 

234 with _sidecar_gates_guard: 

235 lock = _sidecar_gates.get(lock_path) 

236 if lock is None: 

237 lock = threading.Lock() 

238 _sidecar_gates[lock_path] = lock 

239 return lock 

240 

241 

242def _acquire_posix_lock( 

243 fd: int, 

244 lock_path: Path, 

245 *, 

246 mode: AuthorizationLeaseMode = AuthorizationLeaseMode.EXCLUSIVE, 

247 blocking: bool = False, 

248) -> None: 

249 import fcntl 

250 

251 match mode: 

252 case AuthorizationLeaseMode.SHARED: 

253 operation = fcntl.LOCK_SH 

254 case AuthorizationLeaseMode.EXCLUSIVE: 254 ↛ 256line 254 didn't jump to line 256 because the pattern on line 254 always matched

255 operation = fcntl.LOCK_EX 

256 case _ as unreachable: 

257 assert_never(unreachable) 

258 if not blocking: 

259 operation |= fcntl.LOCK_NB 

260 try: 

261 fcntl.flock(fd, operation) 

262 except OSError as error: 

263 if error.errno in {errno.EACCES, errno.EAGAIN}: 

264 contention_error = RekeyLockContentionError(lock_path) 

265 else: 

266 raise 

267 else: 

268 return 

269 raise contention_error 

270 

271 

272def _release_posix_lock(fd: int) -> None: 

273 import fcntl 

274 

275 fcntl.flock(fd, fcntl.LOCK_UN) 

276 

277 

278def _acquire_windows_lock( 

279 fd: int, 

280 lock_path: Path, 

281 *, 

282 mode: AuthorizationLeaseMode = AuthorizationLeaseMode.EXCLUSIVE, 

283 blocking: bool = False, 

284) -> None: 

285 import msvcrt 

286 

287 if os.fstat(fd).st_size == 0: 

288 _ = os.lseek(fd, 0, os.SEEK_SET) 

289 _ = os.write(fd, b"\0") 

290 _ = os.lseek(fd, 0, os.SEEK_SET) 

291 try: 

292 match mode: 

293 case AuthorizationLeaseMode.SHARED: 

294 lock_mode = msvcrt.LK_RLCK if blocking else msvcrt.LK_NBRLCK 

295 case AuthorizationLeaseMode.EXCLUSIVE: 295 ↛ 297line 295 didn't jump to line 297 because the pattern on line 295 always matched

296 lock_mode = msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK 

297 case _ as unreachable: 

298 assert_never(unreachable) 

299 msvcrt.locking(fd, lock_mode, 1) 

300 except OSError as error: 

301 if error.errno in { 

302 errno.EACCES, 

303 errno.EAGAIN, 

304 errno.EDEADLK, 

305 } or getattr(error, "winerror", None) in {33, 36}: 

306 contention_error = RekeyLockContentionError(lock_path) 

307 else: 

308 raise 

309 else: 

310 return 

311 raise contention_error 

312 

313 

314def _release_windows_lock(fd: int) -> None: 

315 import msvcrt 

316 

317 _ = os.lseek(fd, 0, os.SEEK_SET) 

318 msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) 

319 

320 

321def _authorization_lock_path(db_path: Path) -> Path: 

322 return db_path.with_name(f"{db_path.name}.rekey.authorization.lock") 

323 

324 

325def _sidecar_io_error( 

326 lock_request: _SidecarLockRequest, 

327 operation: RekeySidecarOperation, 

328 error: OSError, 

329) -> RekeySidecarIOError: 

330 return RekeySidecarIOError(lock_request.db_path, operation, error.errno) 

331 

332 

333def _open_sidecar(lock_request: _SidecarLockRequest) -> int: 

334 # O_NOFOLLOW: a symlink planted at the sidecar name must not redirect the 

335 # open (or the permission tightening below) onto an unrelated file the 

336 # process can write; it fails with ELOOP and surfaces as a typed OPEN 

337 # error. The mode is tightened through the fd for the same reason. 

338 flags = os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0) 

339 # Checked BEFORE the open: closing any descriptor to the database (or 

340 # its WAL/shm) releases every POSIX lock this process holds on it behind 

341 # SQLite's back, so a sidecar hard-linked to one of them must never be 

342 # opened at all, not even to refuse it. 

343 try: 

344 planted = os.lstat(lock_request.lock_path) 

345 except OSError: 

346 planted = None 

347 if planted is not None and _is_database_file(lock_request, planted): 

348 raise RekeySidecarIOError( 

349 lock_request.db_path, RekeySidecarOperation.OPEN, errno.EMLINK 

350 ) 

351 try: 

352 fd = os.open(lock_request.lock_path, flags, 0o600) 

353 except OSError as error: 

354 open_error = _sidecar_io_error( 

355 lock_request, RekeySidecarOperation.OPEN, error 

356 ) 

357 else: 

358 # O_NOFOLLOW does not catch a HARD link planted at the sidecar name: 

359 # the open lands on the linked inode and fchmod would tighten that 

360 # unrelated file. A hard-linked sidecar is still locked (an advisory 

361 # lock changes nothing in the file, and refusing it locked the user 

362 # out whenever a backup or snapshot tool hard-linked the data 

363 # directory), but its mode is left alone. 

364 refusal = _refuse_foreign_sidecar(lock_request, fd) 

365 if refusal is not None: 

366 _close_refused_sidecar(fd) 

367 raise refusal 

368 try: 

369 hard_linked = os.fstat(fd).st_nlink > 1 

370 except OSError: 

371 hard_linked = True 

372 fchmod = None if hard_linked else getattr(os, "fchmod", None) 

373 if fchmod is not None: 

374 try: 

375 fchmod(fd, 0o600) 

376 except OSError: 

377 return fd 

378 return fd 

379 raise open_error 

380 

381 

382def _is_database_file( 

383 lock_request: _SidecarLockRequest, status: os.stat_result 

384) -> bool: 

385 """True when ``status`` may be the inode of a database file. 

386 

387 A lock file whose inode is also a database, WAL or shm file (this 

388 user's or ANOTHER user's in the same directory) must never be opened: 

389 closing the lock's descriptor releases every POSIX lock this process 

390 holds on that inode behind SQLite's back. Only an inode with more than 

391 one link can be one. This user's own files are stat'ed directly; every 

392 other entry of the sidecar's directory is then compared without 

393 following symlinks, so any other name for the inode in that directory 

394 refuses it. Links only outside the directory (a ``cp -al`` snapshot of 

395 the data directory) stay accepted. A directory that cannot be listed 

396 refuses too: nothing then shows the inode is not a database. 

397 """ 

398 if status.st_nlink < 2: 

399 return False 

400 identity = (status.st_dev, status.st_ino) 

401 db_path = lock_request.db_path 

402 for path in ( 

403 db_path, 

404 db_path.with_name(f"{db_path.name}-wal"), 

405 db_path.with_name(f"{db_path.name}-shm"), 

406 ): 

407 try: 

408 other = os.stat(path) 

409 except OSError: 

410 continue 

411 if (other.st_dev, other.st_ino) == identity: 

412 return True 

413 lock_name = lock_request.lock_path.name 

414 try: 

415 with os.scandir(lock_request.lock_path.parent) as entries: 

416 for entry in entries: 

417 if entry.name == lock_name: 

418 continue 

419 try: 

420 other = entry.stat(follow_symlinks=False) 

421 except OSError: 

422 continue 

423 if (other.st_dev, other.st_ino) == identity: 

424 return True 

425 except OSError: 

426 return True 

427 return False 

428 

429 

430def _refuse_foreign_sidecar( 

431 lock_request: _SidecarLockRequest, fd: int 

432) -> RekeySidecarIOError | None: 

433 try: 

434 status = os.fstat(fd) 

435 except OSError as error: 

436 return _sidecar_io_error( 

437 lock_request, RekeySidecarOperation.OPEN, error 

438 ) 

439 if not stat.S_ISREG(status.st_mode): 

440 return RekeySidecarIOError( 

441 lock_request.db_path, RekeySidecarOperation.OPEN, errno.EINVAL 

442 ) 

443 if _is_database_file(lock_request, status): 443 ↛ 447line 443 didn't jump to line 447 because the condition on line 443 was never true

444 # Planted between the check before the open and the open itself: 

445 # refused here as well, although the close that follows may 

446 # already have dropped this process's locks on the database. 

447 return RekeySidecarIOError( 

448 lock_request.db_path, RekeySidecarOperation.OPEN, errno.EMLINK 

449 ) 

450 if status.st_nlink == 0: 450 ↛ 453line 450 didn't jump to line 453 because the condition on line 450 was never true

451 # Already unlinked: a lock on it would exclude nobody who opens the 

452 # name afterwards. 

453 return RekeySidecarIOError( 

454 lock_request.db_path, RekeySidecarOperation.OPEN, errno.EMLINK 

455 ) 

456 return None 

457 

458 

459def _close_refused_sidecar(fd: int) -> None: 

460 try: 

461 os.close(fd) 

462 except OSError: 

463 return 

464 

465 

466@contextmanager 

467def _acquire_sidecar_lock( 

468 lock_request: _SidecarLockRequest, 

469) -> Generator[None, None, None]: 

470 """Hold one sidecar lock while preserving protected-body failures.""" 

471 gate = _get_sidecar_gate(lock_request.lock_path) 

472 if lock_request.deadline is not None: 

473 gate_timeout: float | None = max( 

474 0.0, lock_request.deadline - time.monotonic() 

475 ) 

476 elif lock_request.gate_wait is not None: 

477 gate_timeout = lock_request.gate_wait 

478 elif lock_request.blocking: 

479 gate_timeout = None 

480 else: 

481 gate_timeout = 0.0 

482 if gate_timeout is None: 

483 acquired = gate.acquire() 

484 else: 

485 acquired = gate.acquire(timeout=gate_timeout) 

486 if not acquired: 

487 raise RekeyLockContentionError(lock_request.lock_path) 

488 try: 

489 yield from _acquire_sidecar_lock_locked(lock_request) 

490 finally: 

491 gate.release() 

492 

493 

494def _acquire_os_lock_with_retry( 

495 fd: int, lock_request: _SidecarLockRequest 

496) -> None: 

497 """Take the OS lock, retrying a non-blocking attempt for a short window. 

498 

499 Every cache probe in any process (``is_user_connected``/``get_session``) 

500 takes this flock for a few milliseconds. A single non-blocking attempt 

501 therefore reported spurious contention whenever it collided with one of 

502 those probes; ``contention_retry`` absorbs such brief holds while a 

503 genuine long-lived owner (another process's rekey) still fails fast. 

504 A lock request with a ``deadline`` retries until that deadline instead. 

505 """ 

506 if lock_request.deadline is not None: 

507 deadline = lock_request.deadline 

508 else: 

509 deadline = time.monotonic() + lock_request.contention_retry 

510 while True: 

511 try: 

512 if os.name == "nt": 512 ↛ 513line 512 didn't jump to line 513 because the condition on line 512 was never true

513 _acquire_windows_lock( 

514 fd, 

515 lock_request.lock_path, 

516 mode=lock_request.mode, 

517 blocking=lock_request.blocking, 

518 ) 

519 else: 

520 _acquire_posix_lock( 

521 fd, 

522 lock_request.lock_path, 

523 mode=lock_request.mode, 

524 blocking=lock_request.blocking, 

525 ) 

526 except RekeyLockContentionError: 

527 if lock_request.blocking or time.monotonic() >= deadline: 

528 raise 

529 time.sleep(_CONTENTION_RETRY_INTERVAL) 

530 else: 

531 return 

532 

533 

534def _acquire_sidecar_lock_locked( 

535 lock_request: _SidecarLockRequest, 

536) -> Generator[None, None, None]: 

537 fd = _open_sidecar(lock_request) 

538 acquired = False 

539 # The failure of the acquire or of the protected body, recorded as it 

540 # passes through. A release failure is added to it as a note, and is 

541 # raised itself only when there is none. Not sys.exception() in the 

542 # finally below: after a clean body, this generator is resumed from 

543 # its caller's frame, and when that caller is inside an except block 

544 # sys.exception() returns the exception IT is handling, so a failed 

545 # unlock or close was attached to that unrelated exception as a note 

546 # and never raised. 

547 active_error: BaseException | None = None 

548 try: 

549 acquire_error: RekeySidecarIOError | None = None 

550 try: 

551 _acquire_os_lock_with_retry(fd, lock_request) 

552 except OSError as error: 

553 acquire_error = _sidecar_io_error( 

554 lock_request, RekeySidecarOperation.ACQUIRE, error 

555 ) 

556 if acquire_error is not None: 

557 raise acquire_error # noqa: TRY301 — recorded as active_error by the except below 

558 acquired = True 

559 yield 

560 except BaseException as failure: 

561 active_error = failure 

562 raise 

563 finally: 

564 cleanup_errors: list[RekeySidecarIOError] = [] 

565 if acquired: 

566 try: 

567 if os.name == "nt": 567 ↛ 568line 567 didn't jump to line 568 because the condition on line 567 was never true

568 _release_windows_lock(fd) 

569 else: 

570 _release_posix_lock(fd) 

571 except OSError as error: 

572 cleanup_errors.append( 

573 _sidecar_io_error( 

574 lock_request, RekeySidecarOperation.UNLOCK, error 

575 ) 

576 ) 

577 try: 

578 os.close(fd) 

579 except OSError as error: 

580 cleanup_errors.append( 

581 _sidecar_io_error( 

582 lock_request, RekeySidecarOperation.CLOSE, error 

583 ) 

584 ) 

585 

586 if active_error is not None: 

587 for cleanup_error in cleanup_errors: 587 ↛ exitline 587 didn't return from function '_acquire_sidecar_lock_locked' because the loop on line 587 didn't complete

588 active_error.add_note(str(cleanup_error)) 

589 elif cleanup_errors: 

590 primary_error = cleanup_errors[0] 

591 for cleanup_error in cleanup_errors[1:]: 591 ↛ 592line 591 didn't jump to line 592 because the loop on line 591 never started

592 primary_error.add_note(str(cleanup_error)) 

593 raise primary_error 

594 

595 

596@contextmanager 

597def acquire_rekey_lock( 

598 db_path: Path, 

599 *, 

600 blocking: bool = False, 

601 local_wait: float | None = None, 

602 contention_retry: float = 0.0, 

603 timeout: float | None = None, 

604) -> Generator[None, None, None]: 

605 """Hold the database's persistent, owner-only rekey sidecar lock. 

606 

607 ``local_wait`` serves the heavyweight caller (change_password): wait up 

608 to that many seconds for IN-PROCESS holders via the sidecar gate. Those 

609 holders are not only brief cache probes: a cold open keeps the gate 

610 through key derivation, alembic migrations and engine build, and an 

611 engine disposal through its WAL checkpoint, so the wait is bounded 

612 rather than assumed short. The flock is then taken non-blocking, 

613 retried for at most ``contention_retry`` seconds to ride out other 

614 processes' millisecond cache probes, so a genuine cross-process owner 

615 still fails fast with ``RekeyLockContentionError``. 

616 

617 ``timeout`` bounds the whole acquisition instead, for the callers on a 

618 request's path (``DatabaseManager``'s cache claim, cold open and 

619 registration): at most that many seconds in total, first on the gate 

620 for in-process holders, then on the flock, which is retried 

621 non-blocking until the same deadline. A holder keeps the sidecar for as 

622 long as its work runs (a rekey or a cold open of a large database), and 

623 a stopped process or a hung mount keeps it indefinitely, so the wait 

624 ends there with ``RekeyLockContentionError``. It overrides ``blocking``, 

625 ``local_wait`` and ``contention_retry``. 

626 """ 

627 lock_request = _SidecarLockRequest( 

628 db_path=db_path, 

629 lock_path=db_path.with_name(f"{db_path.name}.rekey.lock"), 

630 mode=AuthorizationLeaseMode.EXCLUSIVE, 

631 blocking=blocking and timeout is None, 

632 gate_wait=local_wait, 

633 contention_retry=contention_retry, 

634 deadline=None if timeout is None else time.monotonic() + timeout, 

635 ) 

636 with _acquire_sidecar_lock(lock_request): 

637 yield 

638 

639 

640@contextmanager 

641def acquire_authorization_lease( 

642 db_path: Path, 

643 mode: AuthorizationLeaseMode, 

644 blocking: bool = True, 

645) -> Generator[None, None, None]: 

646 """Hold a persistent shared or exclusive authorization sidecar lease.""" 

647 lock_request = _SidecarLockRequest( 

648 db_path=db_path, 

649 lock_path=_authorization_lock_path(db_path), 

650 mode=mode, 

651 blocking=blocking, 

652 ) 

653 with _acquire_sidecar_lock(lock_request): 

654 yield 

655 

656 

657def _epoch_path(db_path: Path) -> Path: 

658 return db_path.with_name(f"{db_path.name}.rekey.epoch") 

659 

660 

661def _pending_epoch_path(db_path: Path) -> Path: 

662 return db_path.with_name(f"{db_path.name}.rekey.pending") 

663 

664 

665# Every token this module writes is a canonical UUID string: 36 ASCII bytes. 

666_EPOCH_TOKEN_MAX_BYTES = len(str(UUID(int=0))) 

667 

668 

669def _read_epoch_file(fd: int) -> bytes: 

670 """Read at most one byte past a token, so an oversized file is detected.""" 

671 data = b"" 

672 while len(data) <= _EPOCH_TOKEN_MAX_BYTES: 672 ↛ 677line 672 didn't jump to line 677 because the condition on line 672 was always true

673 chunk = os.read(fd, _EPOCH_TOKEN_MAX_BYTES + 1 - len(data)) 

674 if not chunk: 

675 break 

676 data += chunk 

677 return data 

678 

679 

680def _close_epoch_file(fd: int) -> None: 

681 # Read-only descriptor: a failed close loses nothing that was read. 

682 try: 

683 os.close(fd) 

684 except OSError: 

685 return 

686 

687 

688def _read_epoch_token(path: Path, db_path: Path) -> str | None: 

689 # The guard _refuse_foreign_sidecar gives the lock sidecar, for these 

690 # reads: they run while the caller holds the sidecar flock (every 

691 # authenticated probe does), so a file planted at the token name must 

692 # fail the read, never hang it or fill memory. O_NOFOLLOW refuses a 

693 # symlink (ELOOP). O_NONBLOCK makes the open of a FIFO return at once 

694 # instead of waiting for a writer, and the fstat below refuses every 

695 # file that is not regular (EINVAL); both are "nothing was read". A 

696 # regular file longer than a token is read no further than one byte 

697 # past it, and does not vouch, like any other malformed token. The link 

698 # count is not checked: unlike the sidecar's fchmod, a read-only open 

699 # cannot change the file a hard link points at. 

700 flags = ( 

701 os.O_RDONLY 

702 | getattr(os, "O_NOFOLLOW", 0) 

703 | getattr(os, "O_NONBLOCK", 0) 

704 | getattr(os, "O_BINARY", 0) 

705 ) 

706 try: 

707 fd = os.open(path, flags) 

708 except FileNotFoundError: 

709 return None 

710 except OSError as error: 

711 raise RekeyEpochUnreadableError(db_path, error.errno) from None 

712 try: 

713 status = os.fstat(fd) 

714 regular = stat.S_ISREG(status.st_mode) 

715 oversized = status.st_size > _EPOCH_TOKEN_MAX_BYTES 

716 raw = _read_epoch_file(fd) if regular and not oversized else b"" 

717 except OSError as error: 

718 raise RekeyEpochUnreadableError(db_path, error.errno) from None 

719 finally: 

720 _close_epoch_file(fd) 

721 if not regular: 

722 raise RekeyEpochUnreadableError(db_path, errno.EINVAL) 

723 if oversized or len(raw) > _EPOCH_TOKEN_MAX_BYTES: 

724 raise RekeyRecoveryRequiredError(db_path) 

725 try: 

726 token = raw.decode("ascii") 

727 except UnicodeError: 

728 raise RekeyRecoveryRequiredError(db_path) from None 

729 

730 try: 

731 canonical = str(UUID(token)) 

732 except ValueError: 

733 raise RekeyRecoveryRequiredError(db_path) from None 

734 if token != canonical: 734 ↛ 735line 734 didn't jump to line 735 because the condition on line 734 was never true

735 raise RekeyRecoveryRequiredError(db_path) 

736 return canonical 

737 

738 

739def read_rekey_epoch_state(db_path: Path) -> RekeyEpochState: 

740 """Read and parse the complete durable rekey state. 

741 

742 The application reads this while holding the user's rekey sidecar 

743 lock, which excludes a concurrent publication, with one exception: 

744 ``DatabaseManager._classify_rekey_failure`` when ``change_password`` 

745 fails after that lock has been released (the read only picks which 

746 error to report). The read order matters for that reader and for the 

747 ones that never take the lock: ``_record_password_verifier`` (only 

748 tests call it) and the ``rekey_epoch`` authorization projection 

749 (nothing in the application calls it yet). Such a reader may run while 

750 a rekey is publishing, so the two files are read in the order that 

751 cannot tear into a stale-but-stable answer. Writers publish ``.pending`` before 

752 they change ``.epoch`` and remove it only after ``.epoch`` is 

753 committed. Reading ``.epoch`` first could see the OLD committed epoch, 

754 then (after a commit + clear landed in between) no pending file, and 

755 report the old epoch as stable. Reading ``.pending`` first means a 

756 missing pending file was missing BEFORE the epoch read, so the epoch 

757 read is at least as new as that moment; a present one reports the 

758 in-flight state. 

759 """ 

760 pending_epoch = _read_epoch_token(_pending_epoch_path(db_path), db_path) 

761 committed_epoch = _read_epoch_token(_epoch_path(db_path), db_path) 

762 if committed_epoch is None and pending_epoch is not None: 

763 raise RekeyRecoveryRequiredError(db_path) 

764 return RekeyEpochState(committed_epoch, pending_epoch) 

765 

766 

767def read_rekey_epoch(db_path: Path) -> str | None: 

768 """Project only a stable committed epoch for cache validation.""" 

769 try: 

770 state = read_rekey_epoch_state(db_path) 

771 except RekeyRecoveryRequiredError: 

772 return None 

773 if state.pending_epoch is not None: 

774 return None 

775 return state.committed_epoch 

776 

777 

778def _canonical_epoch(epoch: str, db_path: Path) -> str: 

779 try: 

780 canonical = str(UUID(epoch)) 

781 except ValueError: 

782 raise RekeyRecoveryRequiredError(db_path) from None 

783 if epoch != canonical: 

784 raise RekeyRecoveryRequiredError(db_path) 

785 return canonical 

786 

787 

788# errnos with which a filesystem says it cannot fsync a directory at all 

789# (vboxsf, some CIFS/9p mounts, WSL DrvFs). There is nothing stronger to 

790# fall back to, so the rename stays as durable as that filesystem makes 

791# it; any other errno (EIO, ENOSPC, EACCES, ...) is a real failure. 

792_DIRECTORY_FSYNC_UNSUPPORTED: frozenset[int] = frozenset( 

793 code 

794 for code in ( 

795 errno.EINVAL, 

796 getattr(errno, "ENOTSUP", None), 

797 getattr(errno, "EOPNOTSUPP", None), 

798 getattr(errno, "ENOSYS", None), 

799 ) 

800 if code is not None 

801) 

802 

803 

804def _sync_parent_directory(path: Path) -> None: 

805 if os.name == "nt": 805 ↛ 806line 805 didn't jump to line 806 because the condition on line 805 was never true

806 return 

807 flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) 

808 directory_fd = os.open(path.parent, flags) 

809 try: 

810 os.fsync(directory_fd) 

811 except OSError as error: 

812 # Unsupported, not failed: treating it as fatal made every epoch 

813 # publication fail on such mounts (the first login after upgrade, 

814 # every password change, every registration). 

815 if error.errno not in _DIRECTORY_FSYNC_UNSUPPORTED: 

816 raise 

817 finally: 

818 os.close(directory_fd) 

819 

820 

821def _write_epoch_token(path: Path, epoch: str, db_path: Path) -> None: 

822 canonical = _canonical_epoch(epoch, db_path) 

823 # The temporary file materialized below holds only a random canonical 

824 # UUID epoch token (no secrets, no user data), is created 0o600 by 

825 # mkstemp inside the database's own encrypted-databases directory, and 

826 # is unlinked in the finally below whether or not the replace lands. 

827 fd, temporary_name = tempfile.mkstemp( # Safe: UUID epoch token only 

828 dir=path.parent, 

829 prefix=f".{path.name}.", 

830 ) 

831 temporary_path = Path(temporary_name) 

832 try: 

833 # Same canonical UUID token, nothing else, before fsync+replace. 

834 stream = os.fdopen(fd, "wb") # Safe: UUID epoch token only 

835 fd = -1 

836 with stream: 

837 _ = stream.write(canonical.encode("ascii")) 

838 stream.flush() 

839 os.fsync(stream.fileno()) 

840 

841 # nosemgrep: semgrep.rules.path-traversal-risk, reason: path is a fixed suffix on the sha256-named user database path and temporary_path is mkstemp output in its directory 

842 os.replace(temporary_path, path) 

843 _sync_parent_directory(path) 

844 finally: 

845 if fd >= 0: 845 ↛ 846line 845 didn't jump to line 846 because the condition on line 845 was never true

846 os.close(fd) 

847 # nosemgrep: semgrep.rules.path-traversal-risk, reason: temporary_path is mkstemp output in the directory of the sha256-named user database path 

848 temporary_path.unlink(missing_ok=True) 

849 

850 

851def begin_rekey_epoch(db_path: Path) -> str: 

852 """Durably publish a fresh pending generation from stable state.""" 

853 state = read_rekey_epoch_state(db_path) 

854 if state.committed_epoch is None or state.pending_epoch is not None: 

855 raise RekeyRecoveryRequiredError(db_path) 

856 pending_epoch = str(uuid4()) 

857 while pending_epoch == state.committed_epoch: 857 ↛ 858line 857 didn't jump to line 858 because the condition on line 857 was never true

858 pending_epoch = str(uuid4()) 

859 _write_epoch_token(_pending_epoch_path(db_path), pending_epoch, db_path) 

860 return pending_epoch 

861 

862 

863def commit_rekey_epoch(db_path: Path, pending_epoch: str) -> None: 

864 """Durably publish the verified pending generation as committed.""" 

865 candidate = _canonical_epoch(pending_epoch, db_path) 

866 state = read_rekey_epoch_state(db_path) 

867 if state.committed_epoch is None or state.pending_epoch != candidate: 

868 raise RekeyRecoveryRequiredError(db_path) 

869 if state.committed_epoch != candidate: 869 ↛ exitline 869 didn't return from function 'commit_rekey_epoch' because the condition on line 869 was always true

870 _write_epoch_token(_epoch_path(db_path), candidate, db_path) 

871 

872 

873def clear_pending_rekey_epoch(db_path: Path, pending_epoch: str) -> None: 

874 """Remove pending metadata only after its generation is committed.""" 

875 candidate = _canonical_epoch(pending_epoch, db_path) 

876 state = read_rekey_epoch_state(db_path) 

877 if state.pending_epoch is None and state.committed_epoch == candidate: 

878 return 

879 if state.committed_epoch != candidate or state.pending_epoch != candidate: 

880 raise RekeyRecoveryRequiredError(db_path) 

881 pending_path = _pending_epoch_path(db_path) 

882 pending_path.unlink() 

883 _sync_parent_directory(pending_path) 

884 

885 

886def advance_rekey_epoch(db_path: Path) -> str: 

887 """Atomically publish a fresh durable rekey epoch.""" 

888 state = read_rekey_epoch_state(db_path) 

889 if state.pending_epoch is not None: 889 ↛ 890line 889 didn't jump to line 890 because the condition on line 889 was never true

890 raise RekeyRecoveryRequiredError(db_path) 

891 epoch = str(uuid4()) 

892 while epoch == state.committed_epoch: 892 ↛ 893line 892 didn't jump to line 893 because the condition on line 892 was never true

893 epoch = str(uuid4()) 

894 _write_epoch_token(_epoch_path(db_path), epoch, db_path) 

895 return epoch 

896 

897 

898def publish_new_database_epoch(db_path: Path) -> str: 

899 """Mint a fresh committed epoch for a database that was just created. 

900 

901 Creation proved no database existed at this path, so any epoch or 

902 pending token left beside it (an account removed without its sidecars, 

903 a restore of only the ``.db``) belongs to an earlier database. Reusing 

904 that epoch would let an engine or identity cached under the earlier 

905 database vouch for the new one, and a leftover pending token would 

906 make the creation fail. The fresh epoch is published first and the 

907 stale pending token removed after it, so a reader never sees the old 

908 epoch as stable. 

909 """ 

910 try: 

911 previous = _read_epoch_token(_epoch_path(db_path), db_path) 

912 except RekeyRecoveryRequiredError: 

913 previous = None 

914 epoch = str(uuid4()) 

915 while epoch == previous: 915 ↛ 916line 915 didn't jump to line 916 because the condition on line 915 was never true

916 epoch = str(uuid4()) 

917 _write_epoch_token(_epoch_path(db_path), epoch, db_path) 

918 pending_path = _pending_epoch_path(db_path) 

919 try: 

920 pending_path.unlink() 

921 except FileNotFoundError: 

922 return epoch 

923 _sync_parent_directory(pending_path) 

924 return epoch 

925 

926 

927def read_or_initialize_rekey_epoch(db_path: Path) -> str: 

928 """Read or initialize the epoch while the stable sidecar lock is held.""" 

929 state = read_rekey_epoch_state(db_path) 

930 if state.pending_epoch is not None: 930 ↛ 931line 930 didn't jump to line 931 because the condition on line 930 was never true

931 raise RekeyRecoveryRequiredError(db_path) 

932 if state.committed_epoch is not None: 

933 return state.committed_epoch 

934 return advance_rekey_epoch(db_path)