Coverage for src/lilbee/core/security.py: 100%
64 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
1"""Security helpers: path validation, input sanitization, secret-file writes."""
3from __future__ import annotations
5import logging
6import os
7import stat
8import sys
9import tempfile
10from collections.abc import Iterator
11from contextlib import contextmanager
12from pathlib import Path
14from filelock import FileLock
15from filelock import Timeout as FileLockTimeout
17log = logging.getLogger(__name__)
19OWNER_ONLY_MODE = 0o600
20OWNER_ONLY_DIR_MODE = 0o700
23@contextmanager
24def file_lock_or_warn(path: Path, timeout_s: float) -> Iterator[None]:
25 """Serialize access to *path* across processes via a sibling ``.lock`` file.
27 On timeout the caller proceeds unserialized: losing coordination to a stale
28 lock file is worse than the rare interleave the lock prevents. filelock
29 applies the owner-only mode under its own lock on every acquire and
30 suppresses a refused chmod only for ``PermissionError``; some NFS/FUSE/SMB
31 mounts refuse it with a different ``OSError`` (e.g. ``ENOTSUP``), which
32 must degrade the same way rather than crash the caller.
33 """
34 flock = FileLock(str(path) + ".lock", mode=OWNER_ONLY_MODE)
35 try:
36 flock.acquire(timeout=timeout_s)
37 except FileLockTimeout:
38 log.warning("Timed out waiting for the %s lock; proceeding without it.", path.name)
39 yield
40 return
41 except OSError:
42 log.warning(
43 "Could not fully acquire the %s lock; proceeding without it.",
44 path.name,
45 exc_info=True,
46 )
47 yield
48 return
49 try:
50 yield
51 finally:
52 flock.release()
55class PathTraversalError(ValueError):
56 """Raised when a caller-supplied path escapes its allowed root.
58 Subclasses ``ValueError`` so existing ``except ValueError`` callers keep
59 working, while letting handlers catch *only* a traversal (not an unrelated
60 downstream ``ValueError`` such as a store dimension mismatch).
61 """
64def validate_path_within(path: str | Path, root: Path) -> Path:
65 """Resolve *path* under *root* and verify it stays within it.
67 A relative *path* is taken as relative to *root*.
68 Raises :class:`PathTraversalError` if the resolved path escapes the root.
69 Returns the resolved path on success.
70 """
71 root_resolved = root.resolve()
72 # Relative paths resolve against the CWD, not *root*, so anchor them here;
73 # a traversal inside is still caught by the containment check below.
74 candidate = Path(path)
75 resolved = (candidate if candidate.is_absolute() else root_resolved / candidate).resolve()
76 if not resolved.is_relative_to(root_resolved):
77 raise PathTraversalError(f"Path escapes allowed directory: {path}")
78 return resolved
81def write_private_text(path: Path, text: str) -> None:
82 """Write *text* to *path* so it is owner-only for its entire existence.
84 Writing under the umask and chmod'ing afterwards leaves a window where any
85 local user can read the file, and these callers persist a bearer token and
86 API keys. ``mkstemp`` creates at 0600 and ``os.replace`` keeps that mode,
87 atomically. The data is fsynced before the rename, so a crash cannot leave
88 the target empty.
90 Windows has no POSIX mode bits; there these rely on the inherited
91 ``%LOCALAPPDATA%`` DACL.
92 """
93 path.parent.mkdir(parents=True, exist_ok=True)
94 fd, tmp_name = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
95 try:
96 with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
97 handle.write(text)
98 handle.flush()
99 os.fsync(handle.fileno())
100 os.replace(tmp_name, path)
101 except BaseException:
102 Path(tmp_name).unlink(missing_ok=True)
103 raise
106def private_opener(path: str, flags: int) -> int:
107 """``open(..., opener=)`` hook that creates a missing file owner-only."""
108 return os.open(path, flags, OWNER_ONLY_MODE)
111def harden_private_file(path: Path) -> None:
112 """Narrow *path* to owner-only, tolerating a file we do not own.
114 A secret file can arrive wider than :func:`write_private_text` leaves it
115 (backup, older release) and is then read indefinitely without a rewrite, so
116 callers narrow on every load. A refused chmod warns rather than raising: a
117 file owned by someone else must not stop the caller from reading it.
119 No-op on Windows, which has no POSIX mode bits.
120 """
121 _narrow_mode(path, OWNER_ONLY_MODE)
124def ensure_private_dir(path: Path) -> None:
125 """Create *path* owner-only, or narrow it when it already exists wider.
127 Same failure semantics as :func:`harden_private_file`.
128 """
129 path.mkdir(mode=OWNER_ONLY_DIR_MODE, parents=True, exist_ok=True)
130 _narrow_mode(path, OWNER_ONLY_DIR_MODE)
133def _narrow_mode(path: Path, mode: int) -> None:
134 """chmod *path* to *mode* unless it already has it; a refused chmod warns."""
135 if sys.platform == "win32": # pragma: no cover - Windows uses the DACL
136 return
137 if stat.S_IMODE(path.stat().st_mode) == mode:
138 return
139 try:
140 path.chmod(mode)
141 except OSError:
142 log.warning("Could not restrict permissions on %s.", path, exc_info=True)