xdg-kit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
xdg_kit/__init__.py ADDED
@@ -0,0 +1,60 @@
1
+ """Secure XDG-style application storage for Python: paths, credentials, permissions, and
2
+ runtime files.
3
+
4
+ The common surface -- directories and secret resolution -- is re-exported here:
5
+
6
+ from xdg_kit import config_dir, data_dir, state_dir, cache_dir, runtime_dir
7
+ from xdg_kit import Credentials, get_secret, require_secret
8
+
9
+ Deeper pieces stay in their modules so the import says what it reaches for:
10
+
11
+ from xdg_kit.backends import FileBackend, KeyringBackend
12
+ from xdg_kit.scrub import scrub_secrets, scrub_exception
13
+ from xdg_kit.locking import FileLock, single_instance
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from importlib.metadata import version
19
+
20
+ from xdg_kit.credentials import (
21
+ Credentials,
22
+ get_secret,
23
+ require_secret,
24
+ secret_names,
25
+ set_secret,
26
+ unset_secret,
27
+ )
28
+ from xdg_kit.errors import (
29
+ CredentialsError,
30
+ InsecureStorageError,
31
+ InvalidAppNameError,
32
+ XdgKitError,
33
+ )
34
+ from xdg_kit.paths import cache_dir, config_dir, data_dir, state_dir
35
+ from xdg_kit.runtime import runtime_dir
36
+
37
+ # Single source of truth is pyproject's version, read from the installed distribution's
38
+ # metadata -- so a release bump lives in one place and `xdg-kit --version` can never drift
39
+ # from what pip resolved. (An editable install reflects a bump after re-sync; importing
40
+ # from an uninstalled source tree raises PackageNotFoundError, which is not a supported use.)
41
+ __version__ = version("xdg-kit")
42
+
43
+ __all__ = [
44
+ "__version__",
45
+ "config_dir",
46
+ "data_dir",
47
+ "state_dir",
48
+ "cache_dir",
49
+ "runtime_dir",
50
+ "Credentials",
51
+ "get_secret",
52
+ "require_secret",
53
+ "set_secret",
54
+ "unset_secret",
55
+ "secret_names",
56
+ "XdgKitError",
57
+ "CredentialsError",
58
+ "InsecureStorageError",
59
+ "InvalidAppNameError",
60
+ ]
xdg_kit/_oslock.py ADDED
@@ -0,0 +1,78 @@
1
+ """One cross-platform exclusive file-lock primitive, shared by the two callers that need
2
+ it: ``locking.FileLock`` (a non-blocking single-instance guard) and ``backends`` (a
3
+ blocking serializer around a store's read-modify-write).
4
+
5
+ Built on ``fcntl.flock`` (POSIX) and ``msvcrt.locking`` (Windows) -- both released by the
6
+ OS automatically when the process exits, even on a crash, so there is no stale lock to
7
+ clean up. On a platform with neither, locking is a no-op that always "succeeds", leaving
8
+ the caller's own in-process guard as the only serialization.
9
+
10
+ The caller owns the lock file (where it lives, how it is opened); this module only takes
11
+ and releases the lock on an already-open handle.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import errno
17
+ from typing import IO
18
+
19
+ try:
20
+ import fcntl
21
+ except ImportError: # non-POSIX
22
+ fcntl = None # type: ignore[assignment]
23
+
24
+ try:
25
+ import msvcrt
26
+ except ImportError: # non-Windows
27
+ msvcrt = None # type: ignore[assignment]
28
+
29
+ __all__ = [
30
+ "lock_exclusive",
31
+ "unlock",
32
+ ]
33
+
34
+
35
+ def lock_exclusive(handle: IO[str], *, blocking: bool) -> bool:
36
+ """Take an exclusive lock on ``handle`` and report whether it was taken. With
37
+ ``blocking=False`` return ``False`` at once when another holder has it. With
38
+ ``blocking=True`` wait for it -- but the wait can still fail to acquire and return
39
+ ``False``: POSIX ``flock`` reports ``ENOLCK`` where locking is unsupported (e.g. some
40
+ network filesystems), so a caller must honour the result rather than assume ``True``.
41
+ Always ``True`` where no OS primitive exists (locking is a no-op)."""
42
+ if fcntl is not None:
43
+ flags = fcntl.LOCK_EX if blocking else fcntl.LOCK_EX | fcntl.LOCK_NB
44
+ try:
45
+ fcntl.flock(handle, flags)
46
+ except OSError:
47
+ return False
48
+ return True
49
+ if msvcrt is not None:
50
+ handle.seek(0)
51
+ if not blocking:
52
+ try:
53
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
54
+ except OSError:
55
+ return False
56
+ return True
57
+ # LK_LOCK blocks only ~10s and then raises EDEADLOCK; re-issue it on that timeout so
58
+ # ``blocking=True`` genuinely waits. Any other error is real -- give up with False.
59
+ while True:
60
+ try:
61
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
62
+ except OSError as err:
63
+ if err.errno == errno.EDEADLOCK:
64
+ handle.seek(0)
65
+ continue
66
+ return False
67
+ return True
68
+ return True
69
+
70
+
71
+ def unlock(handle: IO[str]) -> None:
72
+ """Release the lock taken by ``lock_exclusive`` on ``handle``; a no-op where no OS
73
+ primitive exists."""
74
+ if fcntl is not None:
75
+ fcntl.flock(handle, fcntl.LOCK_UN)
76
+ elif msvcrt is not None:
77
+ handle.seek(0)
78
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
xdg_kit/atomic.py ADDED
@@ -0,0 +1,67 @@
1
+ """Atomic file writes: write a temp file in the target directory, fsync it, then rename
2
+ it over the target.
3
+
4
+ A rename on the same filesystem is atomic, so a crash or a concurrent reader never sees
5
+ a half-written file, and the fsync before the rename means a crash *after* the rename
6
+ cannot leave the target pointing at unflushed bytes. The temp file is created 0600 by
7
+ ``mkstemp`` and its mode is set explicitly before the rename, so a secret is never briefly
8
+ world-readable between create and ``chmod``. Two overlapping writers get distinct temp
9
+ paths, so neither corrupts the other.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import tempfile
16
+ from pathlib import Path
17
+
18
+ from xdg_kit.errors import XdgKitError
19
+
20
+ __all__ = [
21
+ "write_bytes_atomic",
22
+ "write_text_atomic",
23
+ ]
24
+
25
+
26
+ def write_bytes_atomic(path: Path, data: bytes, *, mode: int = 0o600) -> None:
27
+ """Write ``data`` to ``path`` atomically, leaving it mode ``mode`` (0600 by default,
28
+ the right mode for a secret). Creates the parent directory if needed.
29
+
30
+ Raises:
31
+ XdgKitError: the write failed (an I/O error); the partial temp file is removed
32
+ first so a failed write never leaves debris beside the target.
33
+ """
34
+ try:
35
+ path.parent.mkdir(parents=True, exist_ok=True)
36
+ fd, temp_name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp")
37
+ temp_path = Path(temp_name)
38
+ try:
39
+ try:
40
+ handle = os.fdopen(fd, "wb")
41
+ except OSError:
42
+ os.close(fd) # fdopen did not adopt the descriptor -- close it ourselves
43
+ raise
44
+ with handle:
45
+ if hasattr(os, "fchmod"):
46
+ os.fchmod(handle.fileno(), mode) # POSIX: pin the mode before any bytes land
47
+ handle.write(data)
48
+ handle.flush()
49
+ os.fsync(handle.fileno()) # durable before the rename
50
+ os.replace(temp_path, path)
51
+ except OSError:
52
+ try:
53
+ temp_path.unlink(missing_ok=True)
54
+ except OSError:
55
+ pass # cleanup is best-effort; never mask the original write failure below
56
+ raise
57
+ except OSError as err:
58
+ raise XdgKitError(f"could not write {path}: {err}") from err
59
+
60
+
61
+ def write_text_atomic(path: Path, text: str, *, mode: int = 0o600) -> None:
62
+ """Write ``text`` (UTF-8) to ``path`` atomically, leaving it mode ``mode``.
63
+
64
+ Raises:
65
+ XdgKitError: the write failed (propagated from ``write_bytes_atomic``).
66
+ """
67
+ write_bytes_atomic(path, text.encode("utf-8"), mode=mode)
xdg_kit/backends.py ADDED
@@ -0,0 +1,414 @@
1
+ """Where a secret is physically stored, behind one small interface.
2
+
3
+ ``SecretBackend`` is the seam: given an app and a secret name, read, write, remove, or
4
+ list. Two implementations ship:
5
+
6
+ - ``FileBackend`` (the default) -- a flat ``name -> value`` JSON map in
7
+ ``credentials.json`` under ``config_dir(app)``, written atomically at mode 0600 in a
8
+ directory tightened to 0700. It is the reliable base everywhere: no OS session, no
9
+ network, portable across machines, and it works the same headless as on a desktop.
10
+ - ``KeyringBackend`` (opt-in) -- the OS secure store, via the ``keyring`` package. Because
11
+ a login keyring has no backend in headless contexts (cron, containers, servers), it
12
+ takes a ``fallback`` (normally a ``FileBackend``) and delegates the whole operation to it
13
+ whenever ``keyring`` is missing or non-functional -- warning once so the user learns
14
+ their secrets are in the file, not the keyring they asked for. When ``keyring`` *is*
15
+ functional it is authoritative, and a successful write/delete also clears any stale
16
+ plaintext copy from the fallback, so opting into the keyring does not leave (or later
17
+ resurrect) a file copy.
18
+
19
+ The store an app reads is chosen by *name* (``config_dir(app)``), so one app can read
20
+ another's store -- that is how a shared store lets a common key live in one place (see
21
+ ``credentials``).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import sys
28
+ import threading
29
+ from collections.abc import Iterator
30
+ from contextlib import contextmanager
31
+ from pathlib import Path
32
+ from typing import IO, Protocol
33
+
34
+ from xdg_kit._oslock import lock_exclusive, unlock
35
+ from xdg_kit.atomic import write_text_atomic
36
+ from xdg_kit.errors import CredentialsError, XdgKitError
37
+ from xdg_kit.paths import config_dir
38
+ from xdg_kit.permissions import (
39
+ PRIVATE_FILE_MODE,
40
+ restrict_dir_to_owner,
41
+ warn_if_group_or_world_readable,
42
+ )
43
+
44
+ __all__ = [
45
+ "SecretBackend",
46
+ "FileBackend",
47
+ "KeyringBackend",
48
+ "default_backend",
49
+ ]
50
+
51
+ CREDENTIALS_FILE = "credentials.json"
52
+
53
+ _warned_keyring_fallback = False
54
+
55
+
56
+ class SecretBackend(Protocol):
57
+ """The store interface every backend implements. ``get`` returns the stored value or
58
+ ``None`` when the store or key is absent; ``set`` and ``unset`` mutate it; ``names``
59
+ lists the stored keys (never their values). ``value`` is keyword-only so it can never
60
+ be swapped with ``name`` positionally -- a swap would store the secret *as a key
61
+ name*. Surrounding whitespace is not significant: ``get`` returns a value stripped, so a
62
+ whitespace-only value reads back as ``None`` (absent)."""
63
+
64
+ def get(self, app: str, name: str) -> str | None: ...
65
+ def set(self, app: str, name: str, *, value: str) -> None: ...
66
+ def unset(self, app: str, name: str) -> None: ...
67
+ def names(self, app: str) -> list[str]: ...
68
+
69
+
70
+ class FileBackend:
71
+ """Secrets in ``credentials.json`` (a flat JSON object) under ``config_dir(app)``,
72
+ written atomically at mode 0600 in a directory tightened to 0700."""
73
+
74
+ def path(self, app: str) -> Path:
75
+ """The credentials file for ``app``: ``credentials.json`` in ``config_dir(app)``."""
76
+ return config_dir(app) / CREDENTIALS_FILE
77
+
78
+ def get(self, app: str, name: str) -> str | None:
79
+ """Return the value stored under ``name``, or ``None`` when the file or key is
80
+ absent (or the value is not a non-empty string). Warns once if the file is
81
+ readable beyond its owner.
82
+
83
+ Raises:
84
+ CredentialsError: the file exists but is unreadable, not JSON, or not a JSON
85
+ object.
86
+ """
87
+ return _normalize_secret_value(self._load(app).get(name))
88
+
89
+ def set(self, app: str, name: str, *, value: str) -> None:
90
+ """Store ``value`` under ``name``, creating or updating the file at mode 0600 in a
91
+ 0700 directory. The whole read-modify-write is serialized -- always across threads of
92
+ this process, and across processes too wherever the OS file lock can be taken -- so
93
+ concurrent writers to one store do not lose each other's keys.
94
+
95
+ Raises:
96
+ CredentialsError: the existing file is unreadable or malformed, or the write
97
+ failed.
98
+ """
99
+ with _exclusive_store_lock(self.path(app)):
100
+ secret_value_by_name = self._load(app)
101
+ secret_value_by_name[name] = value
102
+ self._save(app, secret_value_by_name)
103
+
104
+ def unset(self, app: str, name: str) -> None:
105
+ """Remove ``name`` from the file if present; a no-op when the file or key is
106
+ absent. The read-modify-write is serialized (see ``set``).
107
+
108
+ Raises:
109
+ CredentialsError: the existing file is unreadable or malformed, or the write
110
+ failed.
111
+ """
112
+ with _exclusive_store_lock(self.path(app)):
113
+ secret_value_by_name = self._load(app)
114
+ if name in secret_value_by_name:
115
+ del secret_value_by_name[name]
116
+ self._save(app, secret_value_by_name)
117
+
118
+ def names(self, app: str) -> list[str]:
119
+ """The stored key names, sorted -- never the values.
120
+
121
+ Raises:
122
+ CredentialsError: the file exists but is unreadable or malformed.
123
+ """
124
+ return sorted(self._load(app))
125
+
126
+ def _load(self, app: str) -> dict[str, object]:
127
+ """Parse ``credentials.json`` into a name -> value dict, or ``{}`` when the file is
128
+ absent. Warns once when a present file is group/world-readable."""
129
+ path = self.path(app)
130
+ try:
131
+ text = path.read_text(encoding="utf-8")
132
+ except FileNotFoundError:
133
+ return {}
134
+ except (OSError, UnicodeDecodeError) as err:
135
+ # UnicodeDecodeError is a ValueError, not an OSError, so name it explicitly or
136
+ # a non-UTF-8 file escapes this boundary as a bare traceback.
137
+ raise CredentialsError(f"could not read {path}: {err}") from err
138
+ warn_if_group_or_world_readable(path, app=app)
139
+ try:
140
+ secret_value_by_name = json.loads(text)
141
+ except json.JSONDecodeError as err:
142
+ raise CredentialsError(f"{path} is not valid JSON: {err}") from err
143
+ if not isinstance(secret_value_by_name, dict):
144
+ raise CredentialsError(f"{path} must contain a JSON object of name to value")
145
+ return secret_value_by_name
146
+
147
+ def _save(self, app: str, secret_value_by_name: dict[str, object]) -> None:
148
+ """Write the map back to ``credentials.json`` atomically at mode 0600, in a config
149
+ directory hardened to 0700 first. Converts the atomic layer's ``XdgKitError`` to
150
+ ``CredentialsError`` to honour the ``set``/``unset`` contract."""
151
+ path = self.path(app)
152
+ restrict_dir_to_owner(path.parent)
153
+ text = json.dumps(secret_value_by_name, indent=2, sort_keys=True) + "\n"
154
+ try:
155
+ write_text_atomic(path, text, mode=PRIVATE_FILE_MODE)
156
+ except XdgKitError as err:
157
+ raise CredentialsError(str(err)) from err
158
+
159
+
160
+ class KeyringBackend:
161
+ """Secrets in the OS keyring (service = ``app``, username = ``name``), via the
162
+ ``keyring`` package, with a file ``fallback`` for headless machines where no keyring
163
+ backend exists.
164
+
165
+ When ``keyring`` is missing or reports no backend, the operation is delegated to
166
+ ``fallback`` in full and a one-time warning is printed so the user knows their secrets
167
+ are in the file, not the keyring. When ``keyring`` is present but a call fails (a locked
168
+ or broken store), ``get`` and ``set`` still fall back to the file, but ``unset`` fails
169
+ closed -- it raises ``CredentialsError`` rather than risk reporting a delete that left
170
+ the secret retrievable from the keyring. When ``keyring`` works it is the sole source,
171
+ and a successful ``set``/``unset`` also clears any file copy so opting into the keyring
172
+ never leaves plaintext behind. ``keyring`` provides no way to enumerate a
173
+ service's keys, so ``names`` reports only what the ``fallback`` holds -- keys stored
174
+ solely in the OS keyring are not listable.
175
+
176
+ One direction cannot be fully closed: a value written to the file fallback *while the
177
+ keyring is down* is not migrated into the keyring on recovery. If on recovery the keyring
178
+ holds *no* entry for that name, ``get`` consults the file and returns it, so the value is
179
+ not lost; but if the keyring still holds an *older* value for that name, that older value
180
+ shadows the newer file value (the keyring is authoritative on a conflict). Re-set the key
181
+ while the keyring is reachable so the new value lands in the keyring; do not try to fix it
182
+ by deleting the keyring entry -- an ``unset`` while the keyring works also clears the file
183
+ copy, losing the newer value.
184
+ """
185
+
186
+ def __init__(self, *, fallback: SecretBackend | None = None) -> None:
187
+ self._fallback = fallback
188
+
189
+ def __repr__(self) -> str:
190
+ fallback = type(self._fallback).__name__ if self._fallback is not None else None
191
+ return f"KeyringBackend(fallback={fallback})"
192
+
193
+ def get(self, app: str, name: str) -> str | None:
194
+ """Return the keyring value for ``name``, or ``None`` when it is set nowhere.
195
+
196
+ Falls back to the file store in two cases: when the keyring is unavailable (any
197
+ failure -- missing package, locked or broken backend), warning once; and when the
198
+ keyring is reachable but holds no entry, so a value written to the file while the
199
+ keyring was down (see the class note on one-way reconciliation) stays readable after
200
+ the keyring recovers instead of being silently hidden.
201
+
202
+ Raises:
203
+ CredentialsError: the keyring is unavailable and no fallback is configured, or a
204
+ consulted fallback file is present but unreadable or malformed.
205
+ """
206
+ try:
207
+ import keyring
208
+ value = keyring.get_password(app, name)
209
+ except Exception as err:
210
+ # Best-effort read: any keyring failure (missing package, locked or broken
211
+ # backend) falls back to the file. A third-party backend's error surface is not
212
+ # fully knowable, so the catch is deliberately broad -- unlike unset, which fails
213
+ # closed because reporting a delete that did not happen would strand a live secret.
214
+ return self._fallback_get(app, name, err)
215
+ cleaned = _normalize_secret_value(value)
216
+ if cleaned is not None:
217
+ return cleaned
218
+ # Keyring reachable but empty for this name: a value may have been written to the file
219
+ # while the keyring was down (_fallback_set), which the keyring never learned. Consult
220
+ # the fallback so that value is not silently hidden once the keyring is back. The
221
+ # keyring stays authoritative on conflicts -- a successful keyring set/unset always
222
+ # clears the file copy, so the file can only hold what the keyring genuinely lacks.
223
+ if self._fallback is not None:
224
+ return self._fallback.get(app, name)
225
+ return None
226
+
227
+ def set(self, app: str, name: str, *, value: str) -> None:
228
+ """Store ``value`` under ``name`` in the keyring; a successful write also clears any
229
+ stale plaintext copy from the fallback file. Falls back to the file store (warning
230
+ once) when the keyring is unavailable.
231
+
232
+ Raises:
233
+ CredentialsError: the keyring is unavailable and no fallback is configured; or the
234
+ keyring write succeeded but a stale file copy could not be cleared.
235
+ """
236
+ try:
237
+ import keyring
238
+ keyring.set_password(app, name, value)
239
+ except Exception as err:
240
+ self._fallback_set(app, name, value, err)
241
+ return
242
+ # keyring is authoritative: drop any stale plaintext copy from the fallback file. The
243
+ # keyring write already succeeded; if clearing the file copy fails, say so loudly (a
244
+ # plaintext copy may remain) without hiding that the secret IS stored.
245
+ if self._fallback is not None:
246
+ try:
247
+ self._fallback.unset(app, name)
248
+ except CredentialsError as err:
249
+ raise CredentialsError(
250
+ f"stored {app}/{name} in the keyring, but a stale plaintext copy may "
251
+ f"remain in the file store and could not be cleared: {err}"
252
+ ) from err
253
+
254
+ def unset(self, app: str, name: str) -> None:
255
+ """Remove ``name`` from the keyring and from any fallback file copy; a no-op when
256
+ absent. Fails closed: a keyring present but erroring on delete raises rather than
257
+ silently reporting a delete that may not have happened.
258
+
259
+ Raises:
260
+ CredentialsError: the keyring is present but the delete failed (a locked or broken
261
+ store); or the delete succeeded but a stale file copy could not be cleared.
262
+ When no keyring backend exists at all, the deletion is delegated to the
263
+ fallback (which raises only if its file is malformed).
264
+ """
265
+ try:
266
+ import keyring
267
+ import keyring.errors
268
+ except ImportError as err:
269
+ self._fallback_unset(app, name, err)
270
+ return
271
+ try:
272
+ keyring.delete_password(app, name)
273
+ except keyring.errors.PasswordDeleteError:
274
+ pass # key already absent -- unset is idempotent
275
+ except keyring.errors.NoKeyringError as err:
276
+ self._fallback_unset(app, name, err) # no backend at all -> the file owns it
277
+ return
278
+ except keyring.errors.KeyringError as err:
279
+ # keyring is present but the delete genuinely failed (a locked store, an I/O
280
+ # error): do NOT report success and silently fall back while the secret may
281
+ # still be retrievable from the keyring -- surface it.
282
+ raise CredentialsError(
283
+ f"could not delete {app}/{name} from the keyring: {err}"
284
+ ) from err
285
+ # deletion succeeded (or the key was absent): also clear any file copy so a later
286
+ # keyring outage cannot resurrect it. The keyring delete already succeeded; if
287
+ # clearing the file copy fails, say so loudly without hiding that fact.
288
+ if self._fallback is not None:
289
+ try:
290
+ self._fallback.unset(app, name)
291
+ except CredentialsError as err:
292
+ raise CredentialsError(
293
+ f"deleted {app}/{name} from the keyring, but a stale plaintext copy may "
294
+ f"remain in the file store and could not be cleared: {err}"
295
+ ) from err
296
+
297
+ def names(self, app: str) -> list[str]:
298
+ """The fallback file's stored key names, sorted -- never the values. The OS keyring
299
+ cannot enumerate its own keys, so a key stored solely in the keyring is not listed.
300
+
301
+ Raises:
302
+ CredentialsError: the fallback file is present but unreadable or malformed.
303
+ """
304
+ # keyring cannot enumerate; report only the fallback's file-stored names.
305
+ return self._fallback.names(app) if self._fallback is not None else []
306
+
307
+ def _fallback_get(self, app: str, name: str, err: Exception) -> str | None:
308
+ if self._fallback is not None:
309
+ _warn_keyring_fallback_once(err)
310
+ return self._fallback.get(app, name)
311
+ raise CredentialsError(f"keyring unavailable for {app}/{name}: {err}") from err
312
+
313
+ def _fallback_set(self, app: str, name: str, value: str, err: Exception) -> None:
314
+ if self._fallback is not None:
315
+ _warn_keyring_fallback_once(err)
316
+ self._fallback.set(app, name, value=value)
317
+ return
318
+ raise CredentialsError(f"keyring unavailable for {app}/{name}: {err}") from err
319
+
320
+ def _fallback_unset(self, app: str, name: str, err: Exception) -> None:
321
+ if self._fallback is not None:
322
+ _warn_keyring_fallback_once(err)
323
+ self._fallback.unset(app, name)
324
+ return
325
+ raise CredentialsError(f"keyring unavailable for {app}/{name}: {err}") from err
326
+
327
+
328
+ def default_backend(*, use_keyring: bool = False) -> SecretBackend:
329
+ """The backend a plain ``Credentials(app)`` uses: a ``FileBackend`` by default, or a
330
+ ``KeyringBackend`` with a ``FileBackend`` fallback when ``use_keyring=True`` opts into
331
+ the OS keyring."""
332
+ if use_keyring:
333
+ return KeyringBackend(fallback=FileBackend())
334
+ return FileBackend()
335
+
336
+
337
+ def _warn_keyring_fallback_once(err: Exception) -> None:
338
+ """Warn once, on stderr, that the OS keyring is unavailable and secrets are going to
339
+ the file backend instead -- so a user who opted into the keyring is not silently
340
+ downgraded to a plaintext file without knowing."""
341
+ global _warned_keyring_fallback
342
+ if _warned_keyring_fallback:
343
+ return
344
+ _warned_keyring_fallback = True
345
+ print(
346
+ f"xdg-kit: warning: OS keyring unavailable ({err}); using the file backend "
347
+ f"(credentials.json, mode 0600) instead",
348
+ file=sys.stderr,
349
+ )
350
+
351
+
352
+ def _normalize_secret_value(value: object) -> str | None:
353
+ """A stored value normalised to a non-empty string, or ``None`` -- so a blank entry
354
+ reads as absent and falls through to the next resolution tier."""
355
+ return value.strip() if isinstance(value, str) and value.strip() else None
356
+
357
+
358
+ # --- store write serialization ------------------------------------------------
359
+ #
360
+ # A file store's set/unset is a read-modify-write: load the JSON, change one key, write it
361
+ # back. The atomic write guards against a *torn* file, not against a lost *update* -- two
362
+ # writers that both read the old map each write back their own change, and the last one wins,
363
+ # silently dropping the other's key. So the whole critical section is held under a lock that
364
+ # serializes across both threads (a per-path threading.Lock) and processes (a blocking OS
365
+ # lock on a sibling .lock file, which -- unlike credentials.json -- is never replaced, so an
366
+ # atomic rename cannot orphan a holder's lock). Where the OS lock cannot be taken (no
367
+ # primitive on the platform, or flock unsupported on a network FS), it degrades to the
368
+ # thread lock alone -- correct for the single-process norm, best-effort across processes.
369
+
370
+ # One lock per distinct store path, kept for the process lifetime. The registry is bounded
371
+ # by the number of stores a process touches (a handful), not by call volume, so a plain dict
372
+ # is right here -- eviction would buy nothing worth the machinery.
373
+ _thread_lock_by_store_path: dict[str, threading.Lock] = {}
374
+ _thread_lock_registry_guard = threading.Lock()
375
+
376
+
377
+ def _thread_lock_for(store_path: str) -> threading.Lock:
378
+ """The process-wide lock for the store at ``store_path``, created on first use. Serializes
379
+ two threads of one process, which an OS file lock alone does not guarantee everywhere."""
380
+ with _thread_lock_registry_guard:
381
+ lock = _thread_lock_by_store_path.get(store_path)
382
+ if lock is None:
383
+ lock = threading.Lock()
384
+ _thread_lock_by_store_path[store_path] = lock
385
+ return lock
386
+
387
+
388
+ @contextmanager
389
+ def _exclusive_store_lock(path: Path) -> Iterator[None]:
390
+ """Hold an exclusive lock over a read-modify-write of the store file ``path``. Degrades
391
+ to thread-only serialization where no OS lock primitive exists, the lock file cannot be
392
+ created, or the OS lock cannot be taken (e.g. ``flock`` unsupported on a network FS) --
393
+ the in-process guarantee still holds, and single-process use is the norm."""
394
+ thread_lock = _thread_lock_for(str(path))
395
+ thread_lock.acquire()
396
+ try:
397
+ restrict_dir_to_owner(path.parent)
398
+ try:
399
+ handle: IO[str] | None = (path.parent / f"{path.name}.lock").open("a+")
400
+ except OSError:
401
+ handle = None # cannot create the lock file: rely on the thread lock alone
402
+ locked = False
403
+ try:
404
+ locked = handle is not None and lock_exclusive(handle, blocking=True)
405
+ yield
406
+ finally:
407
+ if handle is not None:
408
+ try:
409
+ if locked:
410
+ unlock(handle) # only when we actually took it -- never a no-op region
411
+ finally:
412
+ handle.close()
413
+ finally:
414
+ thread_lock.release() # its own finally: always runs, even if unlock/close raises