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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""Cross-process serialization for SQLCipher database rekeys."""
3from __future__ import annotations
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
20class RekeyOperationalError(Exception):
21 """Base for explicit operational failures during password rekey."""
24@unique
25class AuthorizationLeaseMode(StrEnum):
26 """Cross-process authorization lease compatibility modes."""
28 SHARED = "shared"
29 EXCLUSIVE = "exclusive"
32@unique
33class RekeySidecarOperation(StrEnum):
34 """Safe lifecycle stages for sidecar I/O diagnostics."""
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"
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}
55class RekeySidecarIOError(RekeyOperationalError):
56 """Normalized sidecar I/O failure without raw operating-system text."""
58 db_path: Path
59 operation: RekeySidecarOperation
60 errno: int | None
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 )
79@unique
80class RekeyStage(StrEnum):
81 """Closed stages used to classify password-rekey outcomes."""
83 PREPARATION = "preparation"
84 PENDING_PUBLICATION = "pending-publication"
85 PHYSICAL_REKEY = "physical-rekey"
86 CANDIDATE_VERIFICATION = "candidate-verification"
87 COMMIT_PUBLICATION = "commit-publication"
90class RekeyUnavailableError(RekeyOperationalError):
91 """Raised when rekey cannot proceed before its outcome becomes ambiguous."""
93 db_path: Path
94 stage: RekeyStage
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 )
104class RekeyIndeterminateError(RekeyOperationalError):
105 """Raised when durable state cannot prove which password remains active."""
107 db_path: Path
108 stage: RekeyStage
109 state: RekeyEpochState | None
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 )
125class RekeyLockContentionError(RekeyOperationalError):
126 """Raised when another password rekey already owns the database lock."""
128 lock_path: Path
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}")
135class DatabaseKeyCheckUnavailableError(RekeyOperationalError):
136 """The password could not be checked against the database right now.
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 """
147 db_path: Path
148 reason: str
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 )
158class RekeyRecoveryRequiredError(RekeyOperationalError):
159 """Raised when durable rekey metadata requires verified recovery."""
161 db_path: Path
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}")
168class RekeyEpochUnreadableError(RekeyRecoveryRequiredError):
169 """An epoch file exists (or may) but the read itself failed.
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 """
184 errno: int | None
186 def __init__(self, db_path: Path, error_number: int | None) -> None:
187 super().__init__(db_path)
188 self.errno = error_number
191@dataclass(frozen=True, slots=True)
192class RekeyEpochState:
193 """Durable committed and in-flight rekey generations."""
195 committed_epoch: str | None
196 pending_epoch: str | None
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
212# Poll interval while a non-blocking caller rides out a brief foreign hold.
213_CONTENTION_RETRY_INTERVAL = 0.01
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()
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
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
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
272def _release_posix_lock(fd: int) -> None:
273 import fcntl
275 fcntl.flock(fd, fcntl.LOCK_UN)
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
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
314def _release_windows_lock(fd: int) -> None:
315 import msvcrt
317 _ = os.lseek(fd, 0, os.SEEK_SET)
318 msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
321def _authorization_lock_path(db_path: Path) -> Path:
322 return db_path.with_name(f"{db_path.name}.rekey.authorization.lock")
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)
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
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.
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
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
459def _close_refused_sidecar(fd: int) -> None:
460 try:
461 os.close(fd)
462 except OSError:
463 return
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()
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.
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
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 )
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
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.
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``.
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
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
657def _epoch_path(db_path: Path) -> Path:
658 return db_path.with_name(f"{db_path.name}.rekey.epoch")
661def _pending_epoch_path(db_path: Path) -> Path:
662 return db_path.with_name(f"{db_path.name}.rekey.pending")
665# Every token this module writes is a canonical UUID string: 36 ASCII bytes.
666_EPOCH_TOKEN_MAX_BYTES = len(str(UUID(int=0)))
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
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
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
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
739def read_rekey_epoch_state(db_path: Path) -> RekeyEpochState:
740 """Read and parse the complete durable rekey state.
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)
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
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
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)
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)
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())
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)
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
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)
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)
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
898def publish_new_database_epoch(db_path: Path) -> str:
899 """Mint a fresh committed epoch for a database that was just created.
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
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)