caplinks 0.0.2__tar.gz → 0.0.4__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: caplinks
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Summary: Capability links: an unguessable token, an optional password gate, expiry, revocation and attempt limits. Standard library only.
5
5
  Project-URL: Homepage, https://github.com/i2mint/caplinks
6
6
  Project-URL: Repository, https://github.com/i2mint/caplinks
@@ -44,6 +44,9 @@ links.revoke(rec["token"]) # now "missing"
44
44
  ## What is in it
45
45
 
46
46
  - `CapabilityLinks`: create, read, list and revoke links, and gate them on a password. Records are plain dicts in any `MutableMapping` keyed by token: the generic fields are `token`, `created`, `expires`, `password` (a hash) and `revoked`; your own fields (what the link reaches) sit beside them.
47
+ - Gating an id you already have: `create(..., token=existing_id)` keeps the id; `set_password(token, pw_or_None)` and `set_expires(token, when)` change a live link later (a new password ends every open unlock).
48
+ - `AttemptGuard`: the per-link lock-out. `allow(link, client)` before checking a password, `failed(link)` after a wrong one; one client is limited on its own, and a link that takes too many wrong passwords from everyone is locked for a while.
49
+ - `JsonRecords(texts)` and `FileTexts(folder)`: a ready `store` for `CapabilityLinks`, records as `<token>.json` in a text mapping (a local folder: atomic, mode 0600). Pass `valid=` to check your own fields and `deletable=False` to keep revoked links as tombstones.
47
50
  - `ScryptHasher` (stdlib scrypt) behind the `PasswordHasher` protocol (`hash`, `verify -> bool`): pass `hasher=` for argon2 or anything else.
48
51
  - `parse_expires` reads what people write (`30m`, `12h`, `7d`, `2w`, `2026-11-01` meaning the end of that day UTC, an ISO timestamp, `never`) and refuses the past; `is_expired` fails closed.
49
52
  - `SlidingWindowLimiter` behind the `RateLimiter` protocol (`allow(key) -> bool`): count password attempts per client, per link, or both. It is per process; a shared limiter of the same shape (Redis, say) fits where several workers serve the links.
@@ -23,6 +23,9 @@ links.revoke(rec["token"]) # now "missing"
23
23
  ## What is in it
24
24
 
25
25
  - `CapabilityLinks`: create, read, list and revoke links, and gate them on a password. Records are plain dicts in any `MutableMapping` keyed by token: the generic fields are `token`, `created`, `expires`, `password` (a hash) and `revoked`; your own fields (what the link reaches) sit beside them.
26
+ - Gating an id you already have: `create(..., token=existing_id)` keeps the id; `set_password(token, pw_or_None)` and `set_expires(token, when)` change a live link later (a new password ends every open unlock).
27
+ - `AttemptGuard`: the per-link lock-out. `allow(link, client)` before checking a password, `failed(link)` after a wrong one; one client is limited on its own, and a link that takes too many wrong passwords from everyone is locked for a while.
28
+ - `JsonRecords(texts)` and `FileTexts(folder)`: a ready `store` for `CapabilityLinks`, records as `<token>.json` in a text mapping (a local folder: atomic, mode 0600). Pass `valid=` to check your own fields and `deletable=False` to keep revoked links as tombstones.
26
29
  - `ScryptHasher` (stdlib scrypt) behind the `PasswordHasher` protocol (`hash`, `verify -> bool`): pass `hasher=` for argon2 or anything else.
27
30
  - `parse_expires` reads what people write (`30m`, `12h`, `7d`, `2w`, `2026-11-01` meaning the end of that day UTC, an ISO timestamp, `never`) and refuses the past; `is_expired` fails closed.
28
31
  - `SlidingWindowLimiter` behind the `RateLimiter` protocol (`allow(key) -> bool`): count password attempts per client, per link, or both. It is per process; a shared limiter of the same shape (Redis, say) fits where several workers serve the links.
@@ -16,6 +16,7 @@ True
16
16
  """
17
17
 
18
18
  from caplinks.expiry import is_expired, now_utc, parse_expires
19
+ from caplinks.guard import DFLT_MAX_ATTEMPTS, DFLT_MAX_LINK_FAILURES, AttemptGuard
19
20
  from caplinks.limits import RateLimiter, SlidingWindowLimiter
20
21
  from caplinks.links import (
21
22
  DFLT_EXPIRES,
@@ -27,8 +28,14 @@ from caplinks.links import (
27
28
  public_record,
28
29
  )
29
30
  from caplinks.passwords import PasswordHasher, ScryptHasher
31
+ from caplinks.records import FileTexts, JsonRecords
30
32
 
31
33
  __all__ = [
34
+ "AttemptGuard",
35
+ "DFLT_MAX_ATTEMPTS",
36
+ "DFLT_MAX_LINK_FAILURES",
37
+ "FileTexts",
38
+ "JsonRecords",
32
39
  "CapabilityLinks",
33
40
  "DFLT_EXPIRES",
34
41
  "MIN_PASSWORD_LEN",
@@ -0,0 +1,87 @@
1
+ """The per-link lock-out: limit password tries, and lock a link that takes too many.
2
+
3
+ Two counts, because they defend against different things. Tries of one client on one
4
+ link are limited so that a guesser locks out only itself; the wrong passwords a link
5
+ takes from *everyone* are limited too, and past that the link's unlock is closed for a
6
+ while (a distributed guess). A host calls :meth:`AttemptGuard.allow` before it checks a
7
+ password and :meth:`AttemptGuard.failed` after a wrong one.
8
+
9
+ >>> t = [0.0]
10
+ >>> guard = AttemptGuard(
11
+ ... client_limiter=SlidingWindowLimiter(max_per_window=5, clock=lambda: t[0]),
12
+ ... link_limiter=SlidingWindowLimiter(max_per_window=1, clock=lambda: t[0]),
13
+ ... lock_s=60, clock=lambda: t[0])
14
+ >>> guard.allow("link", "1.2.3.4")
15
+ True
16
+ >>> guard.failed("link"); guard.failed("link") # the second one locks the link
17
+ >>> guard.allow("link", "5.6.7.8")
18
+ False
19
+ >>> t[0] = 61.0; guard.allow("link", "5.6.7.8")
20
+ True
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import threading
26
+ import time
27
+ from collections.abc import Callable
28
+
29
+ from caplinks.limits import (
30
+ DFLT_WINDOW_S,
31
+ RateLimiter,
32
+ SlidingWindowLimiter,
33
+ )
34
+
35
+ #: tries one client gets on one link per window
36
+ DFLT_MAX_ATTEMPTS = 10
37
+ #: wrong passwords one link takes from everyone per window before it locks
38
+ DFLT_MAX_LINK_FAILURES = 100
39
+
40
+
41
+ class AttemptGuard:
42
+ """Decide whether a password try may be made, and lock a link that is being guessed.
43
+
44
+ ``client_limiter`` counts every try of one client on one link; ``link_limiter``
45
+ counts the wrong passwords a link takes from everyone and, past its limit, locks the
46
+ link for ``lock_s`` seconds. Both are anything with ``allow(key) -> bool`` (a Redis
47
+ one shares the count between workers); the lock itself is per process. ``clock``
48
+ returns a monotonic time in seconds (tests pin it; the default limiters use it too).
49
+ Keep ``lock_s`` at least the limiter's window, or the first failure after a lock ends
50
+ locks the link again at once.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ *,
56
+ client_limiter: RateLimiter | None = None,
57
+ link_limiter: RateLimiter | None = None,
58
+ lock_s: float = DFLT_WINDOW_S,
59
+ clock: Callable[[], float] = time.monotonic,
60
+ ):
61
+ self.client_limiter = client_limiter or SlidingWindowLimiter(
62
+ max_per_window=DFLT_MAX_ATTEMPTS, window_s=DFLT_WINDOW_S, clock=clock
63
+ )
64
+ self.link_limiter = link_limiter or SlidingWindowLimiter(
65
+ max_per_window=DFLT_MAX_LINK_FAILURES, window_s=DFLT_WINDOW_S, clock=clock
66
+ )
67
+ self.lock_s = lock_s
68
+ self._clock = clock
69
+ self._locked: dict[str, float] = {} # link -> when its lock ends
70
+ self._lock = threading.Lock()
71
+
72
+ def allow(self, link: str, client: str) -> bool:
73
+ """Whether ``client`` may try a password on ``link`` now (counts the try)."""
74
+ with self._lock:
75
+ ends = self._locked.get(link, 0.0)
76
+ if ends > self._clock():
77
+ return False
78
+ if ends:
79
+ del self._locked[link] # expired: do not keep it
80
+ return self.client_limiter.allow(f"{link}:{client}")
81
+
82
+ def failed(self, link: str) -> None:
83
+ """Record a wrong password on ``link``; lock the link if it takes too many."""
84
+ if self.link_limiter.allow(link):
85
+ return
86
+ with self._lock:
87
+ self._locked[link] = self._clock() + self.lock_s
@@ -105,28 +105,32 @@ class CapabilityLinks:
105
105
  *,
106
106
  password: str | None = None,
107
107
  expires=DFLT_EXPIRES,
108
+ token: str | None = None,
108
109
  ) -> dict:
109
110
  """Make a link carrying the host's ``fields``; return its record without the hash.
110
111
 
111
112
  ``expires`` is anything :func:`caplinks.expiry.parse_expires` reads, 7 days by
112
- default (``"never"`` for none); ``password`` gates the link.
113
+ default (``"never"`` for none); ``password`` gates the link. ``token`` gates an
114
+ id that already exists (a host whose links must not change): it must look like
115
+ a token (:func:`check_token`) and be unused; leave it out for a fresh one. The
116
+ caller then owns the id's randomness: use 128 random bits, as :func:`new_token`.
113
117
  """
114
118
  fields = dict(fields or {})
119
+ if token is not None:
120
+ check_token(token)
121
+ if token in self.store: # even a damaged record is not free to overwrite
122
+ raise ValueError("a link with this token already exists")
115
123
  clash = sorted(set(fields) & {*RECORD_FIELDS, *_VIEW_FIELDS})
116
124
  if clash:
117
125
  raise ValueError(f"fields {clash} are the link's own; name yours otherwise")
118
- if password is not None and len(password) < self.min_password_len:
119
- raise ValueError(
120
- f"a link password needs at least {self.min_password_len} characters "
121
- "(anyone can try it); omit it for no password"
122
- )
126
+ hashed = self._hash_password(password)
123
127
  now = self.clock()
124
128
  record = {
125
- "token": new_token(),
129
+ "token": token or new_token(),
126
130
  "created": now.isoformat(),
127
131
  **fields,
128
132
  "expires": parse_expires(expires, now=now),
129
- "password": self.hasher.hash(password) if password else None,
133
+ "password": hashed,
130
134
  "revoked": None,
131
135
  }
132
136
  self.store[record["token"]] = record
@@ -169,6 +173,47 @@ class CapabilityLinks:
169
173
  out.append(rec)
170
174
  return sorted(out, key=lambda r: r["created"], reverse=True)
171
175
 
176
+ def set_password(self, token: str, password: str | None) -> dict:
177
+ """Give an unrevoked link a new password, or ``None`` to open it.
178
+
179
+ A new password ends every open unlock at once. A revoked link stays revoked
180
+ (``KeyError``).
181
+ """
182
+ return self._update(token, password=self._hash_password(password))
183
+
184
+ def set_expires(self, token: str, expires) -> dict:
185
+ """Move an unrevoked link's expiry (anything :func:`parse_expires` reads;
186
+ ``"never"`` for none). An expired link may be given a later one; a revoked link
187
+ stays revoked (``KeyError``).
188
+ """
189
+ return self._update(token, expires=parse_expires(expires, now=self.clock()))
190
+
191
+ def _update(self, token: str, **changes) -> dict:
192
+ # The store has no compare-and-set: the record is read just before the write (the
193
+ # slow hashing is done by the caller) so a revoke that landed meanwhile is not
194
+ # undone. Hosts with several workers writing the
195
+ # same link still need to serialise writes per token.
196
+ rec = self._unrevoked(token)
197
+ if rec is None:
198
+ raise KeyError(token)
199
+ rec.update(changes)
200
+ self.store[token] = rec
201
+ return public_record(rec)
202
+
203
+ def _unrevoked(self, token: str) -> dict | None:
204
+ rec = self.record(token)
205
+ return None if rec is None or rec.get("revoked") else rec
206
+
207
+ def _hash_password(self, password: str | None) -> str | None:
208
+ if password is None:
209
+ return None
210
+ if len(password) < self.min_password_len:
211
+ raise ValueError(
212
+ f"a link password needs at least {self.min_password_len} characters "
213
+ "(anyone can try it); omit it for no password"
214
+ )
215
+ return self.hasher.hash(password)
216
+
172
217
  def revoke(self, token: str) -> dict:
173
218
  """End a link now (idempotent). The record stays, as a tombstone."""
174
219
  rec = self.record(token)
@@ -0,0 +1,158 @@
1
+ """Link records as JSON text in a store of text: a mapping from token to dict.
2
+
3
+ :class:`CapabilityLinks` keeps its records in any ``MutableMapping`` of token to dict.
4
+ :class:`JsonRecords` makes one from a mapping of *text* (``<token>.json`` to the JSON of
5
+ the record), and :class:`FileTexts` is that text mapping over a local folder, written
6
+ atomically and readable by the owner only (a record holds a password hash). A host whose
7
+ text lives elsewhere (ssh, S3) passes its own mapping of text.
8
+
9
+ >>> import tempfile
10
+ >>> from caplinks import CapabilityLinks
11
+ >>> links = CapabilityLinks(JsonRecords(FileTexts(tempfile.mkdtemp())))
12
+ >>> token = links.create({"doc": "d1"})["token"]
13
+ >>> links.status(token)[1]["doc"]
14
+ 'd1'
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import tempfile
22
+ from collections.abc import Callable, Iterator, MutableMapping
23
+ from pathlib import Path
24
+
25
+ from caplinks.links import TOKEN_RE, check_token
26
+
27
+ SUFFIX = ".json"
28
+
29
+
30
+ class JsonRecords(MutableMapping):
31
+ """``{token: dict}`` over a mapping of text, ``<token>.json`` to its JSON.
32
+
33
+ A record that cannot be read, is not a JSON object, or fails ``valid`` raises
34
+ ``KeyError``: the link is then missing (every failure closes the link). ``valid``
35
+ takes the host's own check of its fields. ``deletable=False`` refuses ``del``: a
36
+ host that keeps a revoked link as a tombstone says so. ``indent`` pretty-prints.
37
+
38
+ The contract of ``texts``: reading a missing name raises ``KeyError``.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ texts: MutableMapping[str, str],
44
+ *,
45
+ valid: Callable[[dict], bool] | None = None,
46
+ deletable: bool = True,
47
+ indent: int | None = None,
48
+ ):
49
+ self.texts = texts
50
+ self.valid = valid
51
+ self.deletable = deletable
52
+ self.indent = indent
53
+
54
+ @staticmethod
55
+ def _name(token: str) -> str:
56
+ try:
57
+ return check_token(token) + SUFFIX
58
+ except ValueError:
59
+ raise KeyError(token) from None
60
+
61
+ def __getitem__(self, token: str) -> dict:
62
+ try:
63
+ rec = json.loads(self.texts[self._name(token)])
64
+ except (ValueError, KeyError, OSError, RuntimeError, TypeError):
65
+ raise KeyError(token) from None
66
+ if not isinstance(rec, dict) or (self.valid and not self.valid(rec)):
67
+ raise KeyError(token)
68
+ return rec
69
+
70
+ def __setitem__(self, token: str, record: dict) -> None:
71
+ self.texts[self._name(token)] = json.dumps(record, indent=self.indent)
72
+
73
+ def __delitem__(self, token: str) -> None:
74
+ if not self.deletable:
75
+ raise TypeError("link records are never deleted; revoke the link instead")
76
+ del self.texts[self._name(token)]
77
+
78
+ def __iter__(self) -> Iterator[str]:
79
+ for name in self.texts:
80
+ token = name[: -len(SUFFIX)]
81
+ if name.endswith(SUFFIX) and TOKEN_RE.match(token):
82
+ yield token
83
+
84
+ def __len__(self) -> int:
85
+ return sum(1 for _ in self)
86
+
87
+ def __contains__(self, token) -> bool:
88
+ try:
89
+ return self._name(token) in self.texts
90
+ except (KeyError, TypeError):
91
+ return False
92
+
93
+
94
+ class FileTexts(MutableMapping):
95
+ """``{name: text}`` over the files of one local folder (created on first write).
96
+
97
+ Names are plain file names (no path separator, no leading dot, no NUL); a name whose
98
+ file is a symlink out of the folder is missing. The folder is private (mode 0700:
99
+ the names are link tokens) and a write goes to a hidden temporary file (mode 0600)
100
+ that replaces the target, so a reader never sees half a record.
101
+ """
102
+
103
+ def __init__(self, folder: str | os.PathLike):
104
+ self.folder = Path(folder).expanduser()
105
+
106
+ def _path(self, name: str) -> Path:
107
+ if (
108
+ not isinstance(name, str)
109
+ or not name
110
+ or name.startswith(".")
111
+ or any(c in name for c in "/\\\0")
112
+ ):
113
+ raise KeyError(name)
114
+ path = self.folder / name
115
+ if path.is_symlink():
116
+ raise KeyError(name)
117
+ return path
118
+
119
+ def __getitem__(self, name: str) -> str:
120
+ try:
121
+ return self._path(name).read_text(encoding="utf-8")
122
+ except (FileNotFoundError, IsADirectoryError):
123
+ raise KeyError(name) from None
124
+
125
+ def __setitem__(self, name: str, text: str) -> None:
126
+ path = self._path(name)
127
+ self.folder.mkdir(mode=0o700, parents=True, exist_ok=True)
128
+ fd, tmp = tempfile.mkstemp(dir=self.folder, prefix=".", suffix=".tmp") # 0600
129
+ try:
130
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
131
+ f.write(text)
132
+ f.flush()
133
+ os.fsync(f.fileno())
134
+ os.replace(tmp, path)
135
+ except BaseException:
136
+ Path(tmp).unlink(missing_ok=True)
137
+ raise
138
+
139
+ def __delitem__(self, name: str) -> None:
140
+ try:
141
+ self._path(name).unlink()
142
+ except FileNotFoundError:
143
+ raise KeyError(name) from None
144
+
145
+ def __iter__(self) -> Iterator[str]:
146
+ if self.folder.is_dir():
147
+ for p in self.folder.iterdir():
148
+ if p.is_file() and not p.is_symlink() and not p.name.startswith("."):
149
+ yield p.name
150
+
151
+ def __len__(self) -> int:
152
+ return sum(1 for _ in self)
153
+
154
+ def __contains__(self, name) -> bool:
155
+ try:
156
+ return self._path(name).is_file()
157
+ except (KeyError, TypeError):
158
+ return False
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "caplinks"
9
- version = "0.0.2"
9
+ version = "0.0.4"
10
10
  description = "Capability links: an unguessable token, an optional password gate, expiry, revocation and attempt limits. Standard library only."
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.10"
@@ -266,3 +266,223 @@ def test_parse_expires_far_future_is_a_value_error(value):
266
266
  def test_limiter_refuses_settings_that_disable_it(kw):
267
267
  with pytest.raises(ValueError):
268
268
  SlidingWindowLimiter(**kw)
269
+
270
+
271
+ def test_create_gates_an_existing_id():
272
+ links = CapabilityLinks()
273
+ tid = "A" * 22
274
+ rec = links.create({"x": 1}, password=PW, expires="never", token=tid)
275
+ assert rec["token"] == tid and rec["protected"]
276
+ assert links.status(tid)[0] == "ok"
277
+ with pytest.raises(ValueError, match="already exists"):
278
+ links.create(token=tid)
279
+ with pytest.raises(ValueError, match="not a link token"):
280
+ links.create(token="../x")
281
+
282
+
283
+ def test_set_password_ends_open_unlocks():
284
+ links = CapabilityLinks()
285
+ tok = links.create(password=PW)["token"]
286
+ cookie = links.unlock_value(links.record(tok))
287
+ assert links.unlocked(links.record(tok), cookie, max_age_s=3600)
288
+ assert links.set_password(tok, "another pass")["protected"]
289
+ assert not links.unlocked(links.record(tok), cookie, max_age_s=3600)
290
+ assert not links.set_password(tok, None)["protected"]
291
+ with pytest.raises(ValueError):
292
+ links.set_password(tok, "short")
293
+
294
+
295
+ def test_set_expires_revives_an_expired_link_but_not_a_revoked_one():
296
+ clock = Clock()
297
+ links = CapabilityLinks(clock=clock)
298
+ tok = links.create(expires="1d")["token"]
299
+ clock.t = T0 + timedelta(days=2)
300
+ assert links.status(tok)[0] == "expired"
301
+ links.set_expires(tok, "3d")
302
+ assert links.status(tok)[0] == "ok"
303
+ assert links.set_expires(tok, "never")["expires"] is None
304
+ links.revoke(tok)
305
+ for call in (lambda: links.set_expires(tok, "3d"), lambda: links.set_password(tok, PW)):
306
+ with pytest.raises(KeyError):
307
+ call()
308
+
309
+
310
+ def test_set_password_new_one_opens_old_one_does_not():
311
+ links = CapabilityLinks(hasher=ScryptHasher(n=2**4))
312
+ tok = links.create(password=PW)["token"]
313
+ links.set_password(tok, "another pass")
314
+ rec = links.record(tok)
315
+ assert links.check_password(rec, "another pass") and not links.check_password(rec, PW)
316
+ links.set_password(tok, None)
317
+ assert links.unlocked(links.record(tok), None, max_age_s=60)
318
+
319
+
320
+ def test_setters_refuse_the_past_and_the_missing():
321
+ links = CapabilityLinks()
322
+ tok = links.create()["token"]
323
+ with pytest.raises(ValueError, match="past"):
324
+ links.set_expires(tok, "2000-01-01")
325
+ for call in (lambda: links.set_expires("B" * 22, "1d"), lambda: links.set_password("B" * 22, PW)):
326
+ with pytest.raises(KeyError):
327
+ call()
328
+
329
+
330
+ def test_a_revoked_id_cannot_be_gated_again():
331
+ links = CapabilityLinks()
332
+ tid = "C" * 22
333
+ links.create(token=tid)
334
+ links.revoke(tid)
335
+ with pytest.raises(ValueError, match="already exists"):
336
+ links.create(token=tid)
337
+ assert links.status(tid)[0] == "missing"
338
+
339
+
340
+ def test_create_token_does_not_overwrite_a_damaged_record():
341
+ store = {"D" * 22: {"junk": 1}}
342
+ links = CapabilityLinks(store)
343
+ with pytest.raises(ValueError, match="already exists"):
344
+ links.create(token="D" * 22)
345
+ assert store["D" * 22] == {"junk": 1}
346
+
347
+
348
+
349
+ # -- AttemptGuard, JsonRecords, FileTexts --
350
+
351
+
352
+ def test_guard_limits_one_client_without_locking_the_link():
353
+ from caplinks import AttemptGuard
354
+
355
+ guard = AttemptGuard(client_limiter=SlidingWindowLimiter(max_per_window=2))
356
+ assert [guard.allow("L", "a") for _ in range(3)] == [True, True, False]
357
+ assert guard.allow("L", "b") # another client is not affected
358
+
359
+
360
+ def test_guard_locks_a_link_taking_many_failures_then_reopens():
361
+ from caplinks import AttemptGuard
362
+
363
+ t = [0.0]
364
+ guard = AttemptGuard(
365
+ link_limiter=SlidingWindowLimiter(max_per_window=2, clock=lambda: t[0]),
366
+ lock_s=60,
367
+ clock=lambda: t[0],
368
+ )
369
+ for _ in range(2):
370
+ guard.failed("L")
371
+ assert guard.allow("L", "a") # at the limit, not past it
372
+ guard.failed("L")
373
+ assert not guard.allow("L", "a") and not guard.allow("L", "b")
374
+ assert guard.allow("other", "a")
375
+ t[0] = 60.0
376
+ assert guard.allow("L", "a")
377
+ assert "L" not in guard._locked # pruned
378
+
379
+
380
+ def test_json_records_roundtrip_and_fail_closed(tmp_path):
381
+ from caplinks import FileTexts, JsonRecords
382
+
383
+ recs = JsonRecords(FileTexts(tmp_path / "r"), valid=lambda r: "scope" in r)
384
+ links = CapabilityLinks(recs)
385
+ tok = links.create({"scope": 1}, password=PW)["token"]
386
+ assert links.status(tok)[0] == "ok" and tok in recs and list(recs) == [tok]
387
+ (tmp_path / "r" / f"{tok}.json").write_text("{not json")
388
+ assert links.status(tok)[0] == "missing" and tok in recs
389
+ (tmp_path / "r" / f"{tok}.json").write_text('{"token": "%s"}' % tok)
390
+ assert links.status(tok)[0] == "missing" # fails the host's check
391
+ assert "../x" not in recs
392
+ with pytest.raises(KeyError):
393
+ recs["../x"]
394
+
395
+
396
+ def test_json_records_can_refuse_deletes(tmp_path):
397
+ from caplinks import FileTexts, JsonRecords
398
+
399
+ recs = JsonRecords(FileTexts(tmp_path), deletable=False)
400
+ tok = CapabilityLinks(recs).create()["token"]
401
+ with pytest.raises(TypeError):
402
+ del recs[tok]
403
+
404
+
405
+ def test_file_texts_are_private_atomic_and_confined(tmp_path):
406
+ import os
407
+
408
+ from caplinks import FileTexts
409
+
410
+ texts = FileTexts(tmp_path / "t")
411
+ texts["a.json"] = "one"
412
+ texts["a.json"] = "two"
413
+ assert texts["a.json"] == "two" and list(texts) == ["a.json"]
414
+ assert os.stat(tmp_path / "t" / "a.json").st_mode & 0o777 == 0o600
415
+ assert os.stat(tmp_path / "t").st_mode & 0o777 == 0o700
416
+ assert sorted(p.name for p in (tmp_path / "t").iterdir()) == ["a.json"] # no temp
417
+ for bad in ("../a", ".hidden", "x/y", "", "a\0b"):
418
+ assert bad not in texts
419
+ with pytest.raises(KeyError):
420
+ texts[bad]
421
+ del texts["a.json"]
422
+ with pytest.raises(KeyError):
423
+ del texts["a.json"]
424
+
425
+
426
+ def test_a_failed_write_leaves_the_old_text_and_no_temp_file(tmp_path):
427
+ from caplinks import FileTexts
428
+
429
+ texts = FileTexts(tmp_path)
430
+ texts["a.json"] = "old"
431
+ with pytest.raises(TypeError):
432
+ texts["a.json"] = 5 # not text: the write fails midway
433
+ assert texts["a.json"] == "old"
434
+ assert [p.name for p in tmp_path.iterdir()] == ["a.json"]
435
+
436
+
437
+ def test_stray_files_and_symlinks_do_not_break_listing_or_escape(tmp_path):
438
+ from caplinks import FileTexts, JsonRecords
439
+
440
+ outside = tmp_path / "secret.json"
441
+ outside.write_text("{}")
442
+ folder = tmp_path / "r"
443
+ recs = JsonRecords(FileTexts(folder))
444
+ tok = CapabilityLinks(recs).create()["token"]
445
+ (folder / ".DS_Store").write_text("x")
446
+ (folder / "README.json").write_text("{}")
447
+ (folder / ("S" * 22 + ".json")).symlink_to(outside)
448
+ assert list(recs) == [tok]
449
+ assert set(dict(recs.items())) == {tok}
450
+ assert "S" * 22 not in recs
451
+
452
+
453
+ def test_texts_returning_none_is_a_missing_link(tmp_path):
454
+ from caplinks import JsonRecords
455
+
456
+ class Nones(dict):
457
+ def __getitem__(self, k):
458
+ return None
459
+
460
+ recs = JsonRecords(Nones())
461
+ with pytest.raises(KeyError):
462
+ recs["N" * 22]
463
+
464
+
465
+ def test_guard_default_limiters_follow_the_pinned_clock():
466
+ from caplinks import AttemptGuard
467
+
468
+ t = [0.0]
469
+ guard = AttemptGuard(clock=lambda: t[0])
470
+ assert all(guard.allow("L", "a") for _ in range(10)) and not guard.allow("L", "a")
471
+ t[0] = 301.0
472
+ assert guard.allow("L", "a")
473
+
474
+
475
+ def test_guard_is_safe_under_threads():
476
+ import threading
477
+
478
+ from caplinks import AttemptGuard
479
+
480
+ guard = AttemptGuard(client_limiter=SlidingWindowLimiter(max_per_window=50))
481
+ results = []
482
+ threads = [
483
+ threading.Thread(target=lambda: results.append(guard.allow("L", "a")))
484
+ for _ in range(200)
485
+ ]
486
+ [th.start() for th in threads]
487
+ [th.join() for th in threads]
488
+ assert sum(results) == 50
File without changes
File without changes
File without changes
File without changes
File without changes