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 +60 -0
- xdg_kit/_oslock.py +78 -0
- xdg_kit/atomic.py +67 -0
- xdg_kit/backends.py +414 -0
- xdg_kit/cli.py +240 -0
- xdg_kit/credentials.py +185 -0
- xdg_kit/environment.py +46 -0
- xdg_kit/errors.py +41 -0
- xdg_kit/locking.py +112 -0
- xdg_kit/paths.py +169 -0
- xdg_kit/permissions.py +143 -0
- xdg_kit/py.typed +0 -0
- xdg_kit/runtime.py +67 -0
- xdg_kit/scrub.py +93 -0
- xdg_kit-0.1.0.dist-info/METADATA +301 -0
- xdg_kit-0.1.0.dist-info/RECORD +19 -0
- xdg_kit-0.1.0.dist-info/WHEEL +4 -0
- xdg_kit-0.1.0.dist-info/entry_points.txt +2 -0
- xdg_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|