caplinks 0.0.2__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.
@@ -0,0 +1,119 @@
1
+ .claude/handoffs/
2
+ .claude/scratch/
3
+
4
+ # Byte-compiled / optimized / DLL files
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+
9
+
10
+ .DS_Store
11
+ # C extensions
12
+ *.so
13
+
14
+ # TLS certificates
15
+ ## Ignore all PEM files anywhere
16
+ *.pem
17
+ ## Also ignore any certs directory
18
+ certs/
19
+
20
+ # Distribution / packaging
21
+ .Python
22
+ build/
23
+ develop-eggs/
24
+ dist/
25
+ downloads/
26
+ eggs/
27
+ .eggs/
28
+ lib/
29
+ lib64/
30
+ parts/
31
+ sdist/
32
+ var/
33
+ wheels/
34
+ *.egg-info/
35
+ .installed.cfg
36
+ *.egg
37
+ MANIFEST
38
+ _build
39
+
40
+ # PyInstaller
41
+ # Usually these files are written by a python script from a template
42
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
43
+ *.manifest
44
+ *.spec
45
+
46
+ # Installer logs
47
+ pip-log.txt
48
+ pip-delete-this-directory.txt
49
+
50
+ # Unit test / coverage reports
51
+ htmlcov/
52
+ .tox/
53
+ .coverage
54
+ .coverage.*
55
+ .cache
56
+ nosetests.xml
57
+ coverage.xml
58
+ *.cover
59
+ .hypothesis/
60
+ .pytest_cache/
61
+
62
+ # Translations
63
+ *.mo
64
+ *.pot
65
+
66
+ # Django stuff:
67
+ *.log
68
+ local_settings.py
69
+ db.sqlite3
70
+
71
+ # Flask stuff:
72
+ instance/
73
+ .webassets-cache
74
+
75
+ # Scrapy stuff:
76
+ .scrapy
77
+
78
+ # Sphinx documentation
79
+ docs/_build/
80
+
81
+ # PyBuilder
82
+ target/
83
+
84
+ # Jupyter Notebook
85
+ .ipynb_checkpoints
86
+
87
+ # pyenv
88
+ .python-version
89
+
90
+ # celery beat schedule file
91
+ celerybeat-schedule
92
+
93
+ # SageMath parsed files
94
+ *.sage.py
95
+
96
+ # Environments
97
+ .env
98
+ .venv
99
+ env/
100
+ venv/
101
+ ENV/
102
+ env.bak/
103
+ venv.bak/
104
+
105
+ # Spyder project settings
106
+ .spyderproject
107
+ .spyproject
108
+
109
+ # Rope project settings
110
+ .ropeproject
111
+
112
+ # mkdocs documentation
113
+ /site
114
+
115
+ # mypy
116
+ .mypy_cache/
117
+
118
+ # PyCharm
119
+ .idea
caplinks-0.0.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thor Whalen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.5
2
+ Name: caplinks
3
+ Version: 0.0.2
4
+ Summary: Capability links: an unguessable token, an optional password gate, expiry, revocation and attempt limits. Standard library only.
5
+ Project-URL: Homepage, https://github.com/i2mint/caplinks
6
+ Project-URL: Repository, https://github.com/i2mint/caplinks
7
+ Project-URL: Documentation, https://i2mint.github.io/caplinks
8
+ Author: Thor Whalen
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: capability,expiry,password,rate limit,scrypt,share link,token
12
+ Requires-Python: >=3.10
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
15
+ Requires-Dist: pytest>=7.0; extra == 'dev'
16
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
17
+ Provides-Extra: docs
18
+ Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
19
+ Requires-Dist: sphinx>=6.0; extra == 'docs'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # caplinks
23
+
24
+ Capability links: hand someone a link to one thing, with no account. The token is the permission; a password may gate it; it expires; it can be revoked; password attempts are limited. Standard library only, no web framework.
25
+
26
+ ```
27
+ pip install caplinks
28
+ ```
29
+
30
+ ```python
31
+ from caplinks import CapabilityLinks
32
+
33
+ links = CapabilityLinks() # records in a dict; pass any MutableMapping to persist them
34
+ rec = links.create({"doc": "report-1"}, password="open sesame", expires="7d")
35
+ url = f"https://example.com/s/{rec['token']}/"
36
+
37
+ state, stored = links.status(rec["token"]) # "ok", "expired" or "missing"
38
+ links.check_password(stored, "open sesame") # True
39
+ cookie = links.unlock_value(stored) # what a correct password earns
40
+ links.unlocked(stored, cookie, max_age_s=3600) # True, until the password changes or the link is revoked
41
+ links.revoke(rec["token"]) # now "missing"
42
+ ```
43
+
44
+ ## What is in it
45
+
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
+ - `ScryptHasher` (stdlib scrypt) behind the `PasswordHasher` protocol (`hash`, `verify -> bool`): pass `hasher=` for argon2 or anything else.
48
+ - `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
+ - `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.
50
+
51
+ The unlock value is an HMAC keyed by the stored password hash, so changing the password or revoking the link (which clears the hash) ends every open session at once.
52
+
53
+ ## What stays with your app
54
+
55
+ What a link reaches (its scope), how it is served (routes, cookie name, headers), and the attempt policy. `annals` (share links) is the first host.
56
+
57
+ ## Seams
58
+
59
+ | keyword | default | for |
60
+ |---|---|---|
61
+ | `store` | `dict` | persistence: a dol store, a file-backed mapping, S3 |
62
+ | `hasher` | `ScryptHasher()` | argon2id or another scheme |
63
+ | `clock` | `now_utc` | tests that pin the time |
64
+ | `min_password_len` | 8 | a stricter or laxer gate |
65
+
66
+ A store that cannot vouch for a record raises `KeyError`, `ValueError` or `OSError` when it is read, and the link is then missing; so is a record whose generic fields have the wrong types. Every failure closes the link.
@@ -0,0 +1,45 @@
1
+ # caplinks
2
+
3
+ Capability links: hand someone a link to one thing, with no account. The token is the permission; a password may gate it; it expires; it can be revoked; password attempts are limited. Standard library only, no web framework.
4
+
5
+ ```
6
+ pip install caplinks
7
+ ```
8
+
9
+ ```python
10
+ from caplinks import CapabilityLinks
11
+
12
+ links = CapabilityLinks() # records in a dict; pass any MutableMapping to persist them
13
+ rec = links.create({"doc": "report-1"}, password="open sesame", expires="7d")
14
+ url = f"https://example.com/s/{rec['token']}/"
15
+
16
+ state, stored = links.status(rec["token"]) # "ok", "expired" or "missing"
17
+ links.check_password(stored, "open sesame") # True
18
+ cookie = links.unlock_value(stored) # what a correct password earns
19
+ links.unlocked(stored, cookie, max_age_s=3600) # True, until the password changes or the link is revoked
20
+ links.revoke(rec["token"]) # now "missing"
21
+ ```
22
+
23
+ ## What is in it
24
+
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
+ - `ScryptHasher` (stdlib scrypt) behind the `PasswordHasher` protocol (`hash`, `verify -> bool`): pass `hasher=` for argon2 or anything else.
27
+ - `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
+ - `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.
29
+
30
+ The unlock value is an HMAC keyed by the stored password hash, so changing the password or revoking the link (which clears the hash) ends every open session at once.
31
+
32
+ ## What stays with your app
33
+
34
+ What a link reaches (its scope), how it is served (routes, cookie name, headers), and the attempt policy. `annals` (share links) is the first host.
35
+
36
+ ## Seams
37
+
38
+ | keyword | default | for |
39
+ |---|---|---|
40
+ | `store` | `dict` | persistence: a dol store, a file-backed mapping, S3 |
41
+ | `hasher` | `ScryptHasher()` | argon2id or another scheme |
42
+ | `clock` | `now_utc` | tests that pin the time |
43
+ | `min_password_len` | 8 | a stricter or laxer gate |
44
+
45
+ A store that cannot vouch for a record raises `KeyError`, `ValueError` or `OSError` when it is read, and the link is then missing; so is a record whose generic fields have the wrong types. Every failure closes the link.
@@ -0,0 +1,46 @@
1
+ """Capability links: an unguessable token, an optional password gate, expiry, attempt limits.
2
+
3
+ The parts every app needs to hand someone a link to one thing, with no account: the
4
+ token is the permission, a password may gate it, it expires, it can be revoked, and
5
+ password attempts are limited. Standard library only; no web framework. What a link
6
+ reaches (its scope) and how it is served stay with the app.
7
+
8
+ >>> links = CapabilityLinks() # records in a dict; pass any MutableMapping to persist
9
+ >>> rec = links.create({"doc": "report-1"}, password="open sesame", expires="7d")
10
+ >>> state, stored = links.status(rec["token"])
11
+ >>> state, links.check_password(stored, "open sesame")
12
+ ('ok', True)
13
+ >>> cookie = links.unlock_value(stored)
14
+ >>> links.unlocked(stored, cookie, max_age_s=3600)
15
+ True
16
+ """
17
+
18
+ from caplinks.expiry import is_expired, now_utc, parse_expires
19
+ from caplinks.limits import RateLimiter, SlidingWindowLimiter
20
+ from caplinks.links import (
21
+ DFLT_EXPIRES,
22
+ MIN_PASSWORD_LEN,
23
+ RECORD_FIELDS,
24
+ CapabilityLinks,
25
+ check_token,
26
+ new_token,
27
+ public_record,
28
+ )
29
+ from caplinks.passwords import PasswordHasher, ScryptHasher
30
+
31
+ __all__ = [
32
+ "CapabilityLinks",
33
+ "DFLT_EXPIRES",
34
+ "MIN_PASSWORD_LEN",
35
+ "PasswordHasher",
36
+ "RECORD_FIELDS",
37
+ "RateLimiter",
38
+ "ScryptHasher",
39
+ "SlidingWindowLimiter",
40
+ "check_token",
41
+ "is_expired",
42
+ "new_token",
43
+ "now_utc",
44
+ "parse_expires",
45
+ "public_record",
46
+ ]
@@ -0,0 +1,107 @@
1
+ """When a link stops working: reading an expiry the way a person writes one.
2
+
3
+ An expiry is stored as an ISO UTC timestamp (or ``None`` for never). :func:`parse_expires`
4
+ reads what a person types (``7d``, ``2026-11-01``, an ISO timestamp, ``never``);
5
+ :func:`is_expired` checks a record against the clock and fails closed on a value it
6
+ cannot read.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from datetime import datetime, timedelta, timezone
13
+
14
+ #: duration suffixes accepted by :func:`parse_expires` (``30m``, ``12h``, ``7d``, ``2w``)
15
+ DURATION_UNITS = {"m": "minutes", "h": "hours", "d": "days", "w": "weeks"}
16
+ _DURATION_RE = re.compile(r"^(\d+)\s*([mhdw])$", re.I)
17
+ _DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}")
18
+ #: words that mean "no expiry"
19
+ NEVER = frozenset({"never", "none", "no", ""})
20
+
21
+
22
+ def now_utc() -> datetime:
23
+ """The current time, UTC, second precision (the clock every default reads)."""
24
+ return datetime.now(timezone.utc).replace(microsecond=0)
25
+
26
+
27
+ def _as_utc(when: datetime) -> datetime:
28
+ when = when if when.tzinfo else when.replace(tzinfo=timezone.utc)
29
+ return when.astimezone(timezone.utc).replace(microsecond=0)
30
+
31
+
32
+ def parse_expires(value, *, now: datetime | None = None) -> str | None:
33
+ """An expiry as an ISO UTC timestamp, or ``None`` for never.
34
+
35
+ ``None``, ``""`` and ``"never"`` mean never. Accepts a duration (``30m``, ``12h``,
36
+ ``7d``, ``2w``), a date (``2026-11-01``: the END of that day, UTC, so the link works
37
+ through the day named), an ISO timestamp (a naive one is UTC), or a ``datetime``.
38
+ A moment already past is refused.
39
+
40
+ >>> t0 = datetime(2026, 1, 1, tzinfo=timezone.utc)
41
+ >>> parse_expires("2d", now=t0)
42
+ '2026-01-03T00:00:00+00:00'
43
+ >>> parse_expires("2026-01-05", now=t0)
44
+ '2026-01-05T23:59:59+00:00'
45
+ >>> parse_expires("never") is None
46
+ True
47
+ """
48
+ now = now or now_utc()
49
+ if value is None or (isinstance(value, str) and value.strip().lower() in NEVER):
50
+ return None
51
+ if isinstance(value, datetime):
52
+ when = value
53
+ else:
54
+ s = str(value).strip()
55
+ m = _DURATION_RE.match(s)
56
+ if m:
57
+ n, unit = int(m.group(1)), DURATION_UNITS[m.group(2).lower()]
58
+ try:
59
+ when = now + timedelta(**{unit: n})
60
+ except OverflowError:
61
+ raise ValueError(f"expiry {value!r} is too far away") from None
62
+ elif _DATE_RE.fullmatch(s):
63
+ when = datetime.fromisoformat(s).replace(
64
+ hour=23, minute=59, second=59, tzinfo=timezone.utc
65
+ )
66
+ else:
67
+ try:
68
+ when = datetime.fromisoformat(s.replace("Z", "+00:00"))
69
+ except ValueError:
70
+ raise ValueError(
71
+ f"not an expiry: {value!r}; give a duration (7d, 12h), a date "
72
+ "(2026-11-01) or an ISO timestamp"
73
+ ) from None
74
+ try:
75
+ when = _as_utc(when)
76
+ except OverflowError:
77
+ raise ValueError(f"expiry {value!r} is too far away") from None
78
+ if when <= now:
79
+ raise ValueError(f"expiry {when.isoformat()} is already past")
80
+ return when.isoformat()
81
+
82
+
83
+ def expires_at(record: dict) -> datetime | None:
84
+ """The record's expiry as a UTC ``datetime``, ``None`` for never.
85
+
86
+ Raises ``ValueError`` on a value that is not a timestamp.
87
+ """
88
+ exp = record.get("expires")
89
+ if exp is None:
90
+ return None
91
+ try:
92
+ return _as_utc(datetime.fromisoformat(exp))
93
+ except (TypeError, OverflowError):
94
+ raise ValueError(f"not a timestamp: {exp!r}") from None
95
+
96
+
97
+ def is_expired(record: dict, *, now: datetime | None = None) -> bool:
98
+ """True when the record has an expiry and it is reached (a bad one counts as reached).
99
+
100
+ >>> is_expired({"expires": None}), is_expired({"expires": "garbage"})
101
+ (False, True)
102
+ """
103
+ try:
104
+ when = expires_at(record)
105
+ except ValueError:
106
+ return True # fail closed
107
+ return when is not None and (now or now_utc()) >= when
@@ -0,0 +1,74 @@
1
+ """Limiting password attempts: the ``allow(key) -> bool`` protocol and an in-memory default.
2
+
3
+ A host decides what to count (one client on one link, every wrong password a link
4
+ takes, ...) and keys the limiter accordingly. The protocol is the shape of
5
+ ``heed.security``'s limiters, so its ``RedisRateLimiter`` (one count shared by every
6
+ worker) fits wherever a :class:`RateLimiter` is asked for.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+ import time
13
+ from collections import OrderedDict, deque
14
+ from typing import Callable, Protocol, runtime_checkable
15
+
16
+ #: attempts one key may make in each window, by default
17
+ DFLT_MAX_PER_WINDOW = 10
18
+ DFLT_WINDOW_S = 300.0
19
+ #: keys tracked at most, so a flood of addresses cannot exhaust memory
20
+ DFLT_MAX_KEYS = 50_000
21
+
22
+
23
+ @runtime_checkable
24
+ class RateLimiter(Protocol):
25
+ """Decide whether one more attempt keyed by ``key`` is allowed now."""
26
+
27
+ def allow(self, key: str) -> bool: ...
28
+
29
+
30
+ class SlidingWindowLimiter:
31
+ """At most ``max_per_window`` hits per key per window; in memory, per process.
32
+
33
+ Each worker counts alone (two workers allow twice the attempts); pass a shared
34
+ limiter when that matters. The least recently seen keys are dropped past
35
+ ``max_keys``.
36
+
37
+ >>> t = [0.0]
38
+ >>> lim = SlidingWindowLimiter(max_per_window=2, window_s=10, clock=lambda: t[0])
39
+ >>> [lim.allow("a"), lim.allow("a"), lim.allow("a"), lim.allow("b")]
40
+ [True, True, False, True]
41
+ >>> t[0] = 10.0; lim.allow("a")
42
+ True
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ max_per_window: int = DFLT_MAX_PER_WINDOW,
49
+ window_s: float = DFLT_WINDOW_S,
50
+ max_keys: int = DFLT_MAX_KEYS,
51
+ clock: Callable[[], float] = time.monotonic,
52
+ ):
53
+ if max_per_window < 1 or max_keys < 1:
54
+ raise ValueError("max_per_window and max_keys must be at least 1")
55
+ self.max_per_window = max_per_window
56
+ self.window_s = window_s
57
+ self.max_keys = max_keys
58
+ self._clock = clock
59
+ self._hits: OrderedDict[str, deque] = OrderedDict()
60
+ self._lock = threading.Lock() # web hosts call limiters from a threadpool
61
+
62
+ def allow(self, key: str) -> bool:
63
+ with self._lock:
64
+ now = self._clock()
65
+ hits = self._hits.setdefault(key, deque())
66
+ self._hits.move_to_end(key)
67
+ while len(self._hits) > self.max_keys:
68
+ self._hits.popitem(last=False)
69
+ while hits and hits[0] <= now - self.window_s:
70
+ hits.popleft()
71
+ if len(hits) >= self.max_per_window:
72
+ return False
73
+ hits.append(now)
74
+ return True
@@ -0,0 +1,244 @@
1
+ """Capability links: a record behind an unguessable token, with an optional password gate.
2
+
3
+ Knowing the token is the permission. A record is a plain dict, stored in any
4
+ ``MutableMapping`` keyed by token; the generic fields are :data:`RECORD_FIELDS`, and a
5
+ host puts what the link reaches (its scope) in fields of its own beside them::
6
+
7
+ {"token": "<22 url-safe chars, 128 random bits>",
8
+ "created": "<iso>", "expires": "<iso>" | None,
9
+ "password": "scrypt$..." | None, # a hash; the password is never stored
10
+ "revoked": "<iso>" | None, # a tombstone: a revoked link is missing
11
+ ...the host's fields...}
12
+
13
+ A correct password earns an unlock value (a cookie, for a web host): an HMAC keyed by
14
+ the stored hash, so a new password, or a revoke, which clears the hash, ends every open
15
+ session at once.
16
+
17
+ The store contract: a store that cannot vouch for a record (unreadable, damaged, or
18
+ missing the host's own fields) raises ``KeyError``, ``ValueError`` or ``OSError`` when
19
+ it is read, and the link is then missing. A record whose generic fields have the wrong
20
+ types is missing too: every failure closes the link.
21
+
22
+ >>> links = CapabilityLinks()
23
+ >>> rec = links.create({"what": "report-1"}, password="open sesame", expires="7d")
24
+ >>> links.status(rec["token"])[0], rec["protected"], "password" in rec
25
+ ('ok', True, False)
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import hashlib
31
+ import hmac
32
+ import re
33
+ import secrets
34
+ from collections.abc import Callable, Mapping, MutableMapping
35
+ from datetime import datetime
36
+
37
+ from caplinks.expiry import expires_at, is_expired, now_utc, parse_expires
38
+ from caplinks.passwords import PasswordHasher, ScryptHasher
39
+
40
+ TOKEN_BYTES = 16 # 128 bits
41
+ TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{20,64}$")
42
+ #: the fields every record has; a host's own fields may not reuse these names
43
+ RECORD_FIELDS = ("token", "created", "expires", "password", "revoked")
44
+ #: fields the owner's view of a record adds (:func:`public_record`, ``list``)
45
+ _VIEW_FIELDS = ("protected", "active")
46
+ #: a link lasts this long unless told otherwise (``"never"`` for no expiry)
47
+ DFLT_EXPIRES = "7d"
48
+ #: the shortest link password accepted (a gate tried from anywhere must not be guessable)
49
+ MIN_PASSWORD_LEN = 8
50
+ #: an unlock value issued this far in the future still counts (clock skew between workers)
51
+ _UNLOCK_SKEW_S = 60
52
+
53
+
54
+ def new_token() -> str:
55
+ """A fresh link token: 128 random bits, url-safe."""
56
+ return secrets.token_urlsafe(TOKEN_BYTES)
57
+
58
+
59
+ def check_token(token: str) -> str:
60
+ """Return ``token``; raise ``ValueError`` on anything that could not be one.
61
+
62
+ >>> check_token("../../etc/passwd")
63
+ Traceback (most recent call last):
64
+ ...
65
+ ValueError: not a link token
66
+ """
67
+ if not isinstance(token, str) or not TOKEN_RE.match(token):
68
+ raise ValueError("not a link token")
69
+ return token
70
+
71
+
72
+ def public_record(record: dict) -> dict:
73
+ """A record without its password hash; ``protected`` says whether it has one."""
74
+ out = {k: v for k, v in record.items() if k != "password"}
75
+ out["protected"] = bool(record.get("password"))
76
+ return out
77
+
78
+
79
+ class CapabilityLinks:
80
+ """Create, read, list and revoke capability links, and gate them on a password.
81
+
82
+ ``store`` maps token to record (a ``dict`` by default, in process); ``hasher``
83
+ hashes link passwords (stdlib scrypt by default); ``clock`` returns the current UTC
84
+ ``datetime`` (tests pin it); ``min_password_len`` is the shortest password accepted.
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ store: MutableMapping[str, dict] | None = None,
90
+ *,
91
+ hasher: PasswordHasher | None = None,
92
+ clock: Callable[[], datetime] = now_utc,
93
+ min_password_len: int = MIN_PASSWORD_LEN,
94
+ ):
95
+ self.store = {} if store is None else store
96
+ self.hasher = hasher or ScryptHasher()
97
+ self.clock = clock
98
+ self.min_password_len = min_password_len
99
+
100
+ # -- create / read / list / revoke --
101
+
102
+ def create(
103
+ self,
104
+ fields: Mapping | None = None,
105
+ *,
106
+ password: str | None = None,
107
+ expires=DFLT_EXPIRES,
108
+ ) -> dict:
109
+ """Make a link carrying the host's ``fields``; return its record without the hash.
110
+
111
+ ``expires`` is anything :func:`caplinks.expiry.parse_expires` reads, 7 days by
112
+ default (``"never"`` for none); ``password`` gates the link.
113
+ """
114
+ fields = dict(fields or {})
115
+ clash = sorted(set(fields) & {*RECORD_FIELDS, *_VIEW_FIELDS})
116
+ if clash:
117
+ 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
+ )
123
+ now = self.clock()
124
+ record = {
125
+ "token": new_token(),
126
+ "created": now.isoformat(),
127
+ **fields,
128
+ "expires": parse_expires(expires, now=now),
129
+ "password": self.hasher.hash(password) if password else None,
130
+ "revoked": None,
131
+ }
132
+ self.store[record["token"]] = record
133
+ return public_record(record)
134
+
135
+ def record(self, token: str) -> dict | None:
136
+ """A copy of the stored record (hash included), revoked or not; ``None`` if none."""
137
+ try:
138
+ rec = self.store[check_token(token)]
139
+ except (KeyError, ValueError, OSError):
140
+ return None
141
+ if not _well_formed(rec, token):
142
+ return None # damaged, or filed under another name: grants nothing
143
+ return dict(rec)
144
+
145
+ def status(self, token: str) -> tuple[str, dict | None]:
146
+ """``("ok" | "expired" | "missing", record)``; a revoked link is ``missing``."""
147
+ rec = self.record(token)
148
+ if rec is None or rec.get("revoked"):
149
+ return "missing", None
150
+ if is_expired(rec, now=self.clock()):
151
+ return "expired", rec
152
+ return "ok", rec
153
+
154
+ def list(self, *, include_inactive: bool = False) -> list[dict]:
155
+ """Every live link, newest first, without hashes; ``include_inactive`` adds
156
+ revoked and expired ones.
157
+
158
+ Each record gains ``active`` (neither revoked nor expired).
159
+ """
160
+ now = self.clock()
161
+ out = []
162
+ for token in list(self.store):
163
+ rec = self.record(token)
164
+ if rec is None:
165
+ continue
166
+ rec = public_record(rec)
167
+ rec["active"] = not rec.get("revoked") and not is_expired(rec, now=now)
168
+ if include_inactive or rec["active"]:
169
+ out.append(rec)
170
+ return sorted(out, key=lambda r: r["created"], reverse=True)
171
+
172
+ def revoke(self, token: str) -> dict:
173
+ """End a link now (idempotent). The record stays, as a tombstone."""
174
+ rec = self.record(token)
175
+ if rec is None:
176
+ raise KeyError(token)
177
+ if not rec.get("revoked"):
178
+ rec = {**rec, "revoked": self.clock().isoformat(), "password": None}
179
+ self.store[token] = rec # no hash left: nothing to guess, no session lives
180
+ return public_record(rec)
181
+
182
+ # -- the password gate --
183
+
184
+ def check_password(self, record: dict, password: str) -> bool:
185
+ """Whether ``password`` opens ``record`` (constant time inside the hasher)."""
186
+ hashed = record.get("password")
187
+ return bool(hashed) and self.hasher.verify(hashed, password)
188
+
189
+ def unlock_value(self, record: dict) -> str:
190
+ """What a correct password earns, to send back on later requests: ``<epoch>.<mac>``."""
191
+ issued = int(self.clock().timestamp())
192
+ return f"{issued}.{_unlock_mac(record, issued)}"
193
+
194
+ def unlocked(self, record: dict, value: str | None, *, max_age_s: int) -> bool:
195
+ """Whether ``value`` is a live unlock of ``record`` (no password: always).
196
+
197
+ A revoked record is never unlocked, whatever the caller checked before.
198
+ """
199
+ if record.get("revoked"):
200
+ return False
201
+ if not record.get("password"):
202
+ return True
203
+ if not value or "." not in value:
204
+ return False
205
+ issued_s, _, mac = value.partition(".")
206
+ try:
207
+ issued = int(issued_s)
208
+ except ValueError:
209
+ return False
210
+ now = int(self.clock().timestamp())
211
+ if not (now - max_age_s <= issued <= now + _UNLOCK_SKEW_S):
212
+ return False
213
+ expected = _unlock_mac(record, issued)
214
+ return hmac.compare_digest(mac.encode("utf-8"), expected.encode("ascii"))
215
+
216
+ def unlock_max_age(self, record: dict, *, max_age_s: int) -> int:
217
+ """How long an unlock should last, in seconds: ``max_age_s``, never past expiry."""
218
+ try:
219
+ ends = expires_at(record)
220
+ except ValueError:
221
+ return 1
222
+ if ends is None:
223
+ return max_age_s
224
+ left = int((ends - self.clock()).total_seconds())
225
+ return max(1, min(max_age_s, left))
226
+
227
+
228
+ def _well_formed(rec, token: str) -> bool:
229
+ """The generic fields have the types every method relies on."""
230
+ optional_str = lambda v: v is None or isinstance(v, str) # noqa: E731
231
+ return (
232
+ isinstance(rec, dict)
233
+ and rec.get("token") == token
234
+ and isinstance(rec.get("created"), str)
235
+ and all(optional_str(rec.get(k)) for k in ("expires", "password", "revoked"))
236
+ )
237
+
238
+
239
+ def _unlock_mac(record: dict, issued: int) -> str:
240
+ # Keyed by the password hash: it holds a random salt and never leaves the server, and
241
+ # a new password (or a revoke, which clears it) invalidates every unlock at once.
242
+ key = (record.get("password") or "").encode("utf-8")
243
+ msg = f"{record['token']}:{issued}".encode("ascii")
244
+ return hmac.new(key, msg, hashlib.sha256).hexdigest()
@@ -0,0 +1,74 @@
1
+ """Password hashing for a link's password gate: a protocol and a stdlib default.
2
+
3
+ A link password is a gate, not an account: it is hashed once when the link is made and
4
+ checked on each unlock. :class:`ScryptHasher` needs nothing beyond the standard library;
5
+ anything with the same two methods (an argon2id adapter, say) fits the ``hasher`` seam
6
+ of :class:`caplinks.CapabilityLinks`.
7
+
8
+ >>> h = ScryptHasher(n=2**4) # tiny cost, for the example only
9
+ >>> stored = h.hash("correct horse")
10
+ >>> stored.startswith("scrypt$"), h.verify(stored, "correct horse"), h.verify(stored, "x")
11
+ (True, True, False)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import hashlib
18
+ import hmac
19
+ import secrets
20
+ from dataclasses import dataclass
21
+ from typing import Protocol, runtime_checkable
22
+
23
+
24
+ @runtime_checkable
25
+ class PasswordHasher(Protocol):
26
+ """What a link needs to gate on a password: ``verify`` returns a bool, never raises."""
27
+
28
+ def hash(self, password: str) -> str: ...
29
+
30
+ def verify(self, hashed: str, password: str) -> bool: ...
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ScryptHasher:
35
+ """Stdlib ``hashlib.scrypt``, stored as ``scrypt$n$r$p$salt$hash`` (base64).
36
+
37
+ The parameters travel with the hash, so raising ``n`` later still verifies old
38
+ hashes. ``n=2**14, r=8`` costs ~16 MB and tens of ms per check, which a host's
39
+ attempt limit (:mod:`caplinks.limits`) bounds.
40
+ """
41
+
42
+ n: int = 2**14
43
+ r: int = 8
44
+ p: int = 1
45
+ salt_bytes: int = 16
46
+ dklen: int = 32
47
+
48
+ def _derive(self, password: str, salt: bytes, n: int, r: int, p: int) -> bytes:
49
+ return hashlib.scrypt(
50
+ password.encode("utf-8"),
51
+ salt=salt,
52
+ n=n,
53
+ r=r,
54
+ p=p,
55
+ dklen=self.dklen,
56
+ maxmem=2 * 128 * r * n + 2**20,
57
+ )
58
+
59
+ def hash(self, password: str) -> str:
60
+ salt = secrets.token_bytes(self.salt_bytes)
61
+ dk = self._derive(password, salt, self.n, self.r, self.p)
62
+ b64 = lambda b: base64.b64encode(b).decode("ascii") # noqa: E731
63
+ return f"scrypt${self.n}${self.r}${self.p}${b64(salt)}${b64(dk)}"
64
+
65
+ def verify(self, hashed: str, password: str) -> bool:
66
+ try:
67
+ scheme, n, r, p, salt, dk = hashed.split("$")
68
+ if scheme != "scrypt":
69
+ return False
70
+ expected = base64.b64decode(dk)
71
+ got = self._derive(password, base64.b64decode(salt), int(n), int(r), int(p))
72
+ except (ValueError, TypeError, AttributeError):
73
+ return False
74
+ return hmac.compare_digest(got, expected)
@@ -0,0 +1,174 @@
1
+ [build-system]
2
+ requires = [
3
+ "hatchling",
4
+ ]
5
+ build-backend = "hatchling.build"
6
+
7
+ [project]
8
+ name = "caplinks"
9
+ version = "0.0.2"
10
+ description = "Capability links: an unguessable token, an optional password gate, expiry, revocation and attempt limits. Standard library only."
11
+ readme = "README.md"
12
+ requires-python = ">=3.10"
13
+ license = "MIT"
14
+ keywords = ["capability", "share link", "token", "password", "expiry", "rate limit", "scrypt"]
15
+ authors = [
16
+ { name = "Thor Whalen" },
17
+ ]
18
+ dependencies = []
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/i2mint/caplinks"
22
+ Repository = "https://github.com/i2mint/caplinks"
23
+ Documentation = "https://i2mint.github.io/caplinks"
24
+
25
+ [project.optional-dependencies]
26
+ dev = [
27
+ "pytest>=7.0",
28
+ "pytest-cov>=4.0",
29
+ "ruff>=0.1.0",
30
+ ]
31
+ docs = [
32
+ "sphinx>=6.0",
33
+ "sphinx-rtd-theme>=1.0",
34
+ ]
35
+
36
+ [tool.hatch.build]
37
+ skip-excluded-dirs = true
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["caplinks"]
41
+
42
+ [tool.hatch.build.targets.sdist]
43
+ exclude = [
44
+ ".claude",
45
+ ".github",
46
+ "scrap",
47
+ ]
48
+
49
+ [tool.ruff]
50
+ line-length = 88
51
+ target-version = "py310"
52
+ exclude = [
53
+ "**/*.ipynb",
54
+ "**/*.md",
55
+ ".git",
56
+ ".venv",
57
+ "build",
58
+ "dist",
59
+ "tests",
60
+ "examples",
61
+ "scrap",
62
+ ]
63
+
64
+ [tool.ruff.lint]
65
+ select = [
66
+ "D100",
67
+ ]
68
+ ignore = [
69
+ "D203",
70
+ "E501",
71
+ "B905",
72
+ ]
73
+
74
+ [tool.ruff.lint.pydocstyle]
75
+ convention = "google"
76
+
77
+ [tool.ruff.lint.per-file-ignores]
78
+ "**/tests/*" = [
79
+ "D",
80
+ ]
81
+ "**/examples/*" = [
82
+ "D",
83
+ ]
84
+ "**/scrap/*" = [
85
+ "D",
86
+ ]
87
+
88
+ [tool.pytest.ini_options]
89
+ minversion = "6.0"
90
+ testpaths = [
91
+ "caplinks",
92
+ "tests",
93
+ ]
94
+ addopts = "--doctest-modules"
95
+ doctest_optionflags = [
96
+ "NORMALIZE_WHITESPACE",
97
+ "ELLIPSIS",
98
+ ]
99
+
100
+ [tool.wads.ci]
101
+ project_name = ""
102
+
103
+ [tool.wads.ci.install]
104
+ extras = "dev"
105
+
106
+ [tool.wads.ci.commands]
107
+ pre_test = []
108
+ test = []
109
+ post_test = []
110
+ lint = []
111
+ format = []
112
+
113
+ [tool.wads.ci.env]
114
+ required_envvars = []
115
+ test_envvars = []
116
+ extra_envvars = []
117
+
118
+ [tool.wads.ci.env.defaults]
119
+
120
+ [tool.wads.ci.quality.ruff]
121
+ enabled = true
122
+
123
+ [tool.wads.ci.quality.black]
124
+ enabled = false
125
+
126
+ [tool.wads.ci.quality.mypy]
127
+ enabled = false
128
+
129
+ [tool.wads.ci.testing]
130
+ enabled = true
131
+ python_versions = [
132
+ "3.10",
133
+ "3.12",
134
+ ]
135
+ pytest_args = [
136
+ "-v",
137
+ "--tb=short",
138
+ ]
139
+ coverage_enabled = true
140
+ coverage_threshold = 0
141
+ coverage_report_format = [
142
+ "term",
143
+ "xml",
144
+ ]
145
+ exclude_paths = [
146
+ "examples",
147
+ "scrap",
148
+ ]
149
+ test_on_windows = false
150
+
151
+ [tool.wads.ci.metrics]
152
+ enabled = true
153
+ config_path = ".github/umpyre-config.yml"
154
+ storage_branch = "code-metrics"
155
+ python_version = "3.11"
156
+ force_run = false
157
+
158
+ [tool.wads.ci.build]
159
+ sdist = true
160
+ wheel = true
161
+
162
+ [tool.wads.ci.publish]
163
+ enabled = true
164
+ skip_ci_marker = "[skip ci]"
165
+ publish_marker = "[publish]"
166
+
167
+ [tool.wads.ci.docs]
168
+ enabled = true
169
+ builder = "epythet"
170
+ ignore_paths = [
171
+ "tests/",
172
+ "scrap/",
173
+ "examples/",
174
+ ]
@@ -0,0 +1,268 @@
1
+ from datetime import datetime, timedelta, timezone
2
+
3
+ import pytest
4
+
5
+ from caplinks import (
6
+ CapabilityLinks,
7
+ RateLimiter,
8
+ ScryptHasher,
9
+ SlidingWindowLimiter,
10
+ is_expired,
11
+ parse_expires,
12
+ )
13
+
14
+ T0 = datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)
15
+ PW = "correct horse"
16
+
17
+
18
+ class Clock:
19
+ def __init__(self, t=T0):
20
+ self.t = t
21
+
22
+ def __call__(self):
23
+ return self.t
24
+
25
+
26
+ @pytest.fixture
27
+ def clock():
28
+ return Clock()
29
+
30
+
31
+ @pytest.fixture
32
+ def links(clock):
33
+ return CapabilityLinks(hasher=ScryptHasher(n=2**4), clock=clock)
34
+
35
+
36
+ def test_smoke_create_open_unlock_revoke():
37
+ """The one-command path, every seam on its default."""
38
+ links = CapabilityLinks()
39
+ rec = links.create({"doc": "d1"}, password=PW)
40
+ state, stored = links.status(rec["token"])
41
+ assert state == "ok" and stored["doc"] == "d1"
42
+ assert links.check_password(stored, PW)
43
+ cookie = links.unlock_value(stored)
44
+ assert links.unlocked(stored, cookie, max_age_s=60)
45
+ links.revoke(rec["token"])
46
+ assert links.status(rec["token"]) == ("missing", None)
47
+
48
+
49
+ def test_create_keeps_host_fields_and_never_returns_the_hash(links):
50
+ rec = links.create({"root": {"id": "x"}, "docs": {"x": ["a.md"]}}, password=PW)
51
+ assert rec["root"] == {"id": "x"} and rec["protected"] is True
52
+ assert "password" not in rec
53
+ stored = links.store[rec["token"]]
54
+ assert stored["password"].startswith("scrypt$") and PW not in stored["password"]
55
+ assert stored["expires"] == (T0 + timedelta(days=7)).isoformat()
56
+
57
+
58
+ def test_create_refuses_reserved_fields_and_short_passwords(links):
59
+ with pytest.raises(ValueError, match="link's own"):
60
+ links.create({"expires": "never"})
61
+ with pytest.raises(ValueError, match="at least 8"):
62
+ links.create(password="short")
63
+ assert links.create(password=None)["protected"] is False
64
+
65
+
66
+ def test_status_ok_expired_missing(links, clock):
67
+ tok = links.create(expires="1h")["token"]
68
+ assert links.status(tok)[0] == "ok"
69
+ clock.t = T0 + timedelta(hours=1)
70
+ assert links.status(tok)[0] == "expired"
71
+ assert links.status("A" * 22) == ("missing", None)
72
+ assert links.status("../etc") == ("missing", None)
73
+
74
+
75
+ def test_a_record_the_store_cannot_vouch_for_is_missing(clock):
76
+ class Picky(dict):
77
+ def __getitem__(self, k):
78
+ raise ValueError("damaged")
79
+
80
+ links = CapabilityLinks(Picky(), clock=clock)
81
+ links.store["A" * 22] = {"token": "A" * 22}
82
+ assert links.record("A" * 22) is None
83
+
84
+
85
+ def test_a_record_filed_under_another_token_grants_nothing(links):
86
+ rec = links.create()
87
+ other = "B" * 22
88
+ links.store[other] = dict(links.store[rec["token"]])
89
+ assert links.record(other) is None and links.status(other)[0] == "missing"
90
+
91
+
92
+ def test_list_newest_first_and_all_adds_inactive(links, clock):
93
+ a = links.create(expires="1h")["token"]
94
+ clock.t = T0 + timedelta(minutes=1)
95
+ b = links.create()["token"]
96
+ c = links.create()["token"]
97
+ links.revoke(c)
98
+ assert [r["token"] for r in links.list()] == [b, a]
99
+ clock.t = T0 + timedelta(hours=2)
100
+ assert [r["token"] for r in links.list()] == [b]
101
+ everything = links.list(include_inactive=True)
102
+ assert {r["token"] for r in everything} == {a, b, c}
103
+ assert all("password" not in r for r in everything)
104
+ assert {r["token"]: r["active"] for r in everything} == {
105
+ a: False,
106
+ b: True,
107
+ c: False,
108
+ }
109
+
110
+
111
+ def test_revoke_is_idempotent_clears_the_hash_and_raises_on_unknown(links, clock):
112
+ tok = links.create(password=PW)["token"]
113
+ first = links.revoke(tok)
114
+ clock.t = T0 + timedelta(hours=1)
115
+ assert links.revoke(tok)["revoked"] == first["revoked"]
116
+ assert links.store[tok]["password"] is None
117
+ with pytest.raises(KeyError):
118
+ links.revoke("C" * 22)
119
+
120
+
121
+ def test_unlock_value_lifecycle(links, clock):
122
+ rec = links.store[links.create(password=PW)["token"]]
123
+ value = links.unlock_value(rec)
124
+ assert links.unlocked(rec, value, max_age_s=3600)
125
+ assert not links.unlocked(
126
+ rec, value[:-1] + ("1" if value[-1] == "0" else "0"), max_age_s=3600
127
+ )
128
+ assert not links.unlocked(rec, None, max_age_s=3600)
129
+ assert not links.unlocked(rec, "nonsense", max_age_s=3600)
130
+ clock.t = T0 + timedelta(hours=2)
131
+ assert not links.unlocked(rec, value, max_age_s=3600) # too old
132
+
133
+
134
+ def test_a_new_password_ends_every_unlock(links):
135
+ rec = links.store[links.create(password=PW)["token"]]
136
+ value = links.unlock_value(rec)
137
+ rec = {**rec, "password": links.hasher.hash("another one")}
138
+ assert not links.unlocked(rec, value, max_age_s=3600)
139
+
140
+
141
+ def test_an_open_link_is_always_unlocked(links):
142
+ rec = links.store[links.create()["token"]]
143
+ assert links.unlocked(rec, None, max_age_s=1)
144
+ assert not links.check_password(rec, "anything")
145
+
146
+
147
+ def test_unlock_max_age_never_outlives_the_link(links, clock):
148
+ rec = links.store[links.create(expires="1h")["token"]]
149
+ assert links.unlock_max_age(rec, max_age_s=7 * 86400) == 3600
150
+ assert links.unlock_max_age(rec, max_age_s=60) == 60
151
+ never = links.store[links.create(expires="never")["token"]]
152
+ assert links.unlock_max_age(never, max_age_s=60) == 60
153
+ clock.t = T0 + timedelta(hours=3)
154
+ assert links.unlock_max_age(rec, max_age_s=60) == 1
155
+
156
+
157
+ def test_hasher_rejects_foreign_or_broken_hashes():
158
+ h = ScryptHasher(n=2**4)
159
+ assert not h.verify("argon2$whatever", PW)
160
+ assert not h.verify("scrypt$not$enough", PW)
161
+ assert not h.verify(None, PW)
162
+ assert h.hash(PW) != h.hash(PW) # salted
163
+
164
+
165
+ @pytest.mark.parametrize(
166
+ "value, expected",
167
+ [
168
+ ("30m", T0 + timedelta(minutes=30)),
169
+ ("12H", T0 + timedelta(hours=12)),
170
+ ("2w", T0 + timedelta(weeks=2)),
171
+ ("2026-01-02", datetime(2026, 1, 2, 23, 59, 59, tzinfo=timezone.utc)),
172
+ ("2026-01-02T10:00:00Z", datetime(2026, 1, 2, 10, tzinfo=timezone.utc)),
173
+ ("2026-01-02T10:00:00", datetime(2026, 1, 2, 10, tzinfo=timezone.utc)),
174
+ (
175
+ datetime(2026, 2, 1, tzinfo=timezone.utc),
176
+ datetime(2026, 2, 1, tzinfo=timezone.utc),
177
+ ),
178
+ ],
179
+ )
180
+ def test_parse_expires_reads_what_people_write(value, expected):
181
+ assert parse_expires(value, now=T0) == expected.isoformat()
182
+
183
+
184
+ @pytest.mark.parametrize("value", [None, "", "never", " None "])
185
+ def test_parse_expires_never(value):
186
+ assert parse_expires(value, now=T0) is None
187
+
188
+
189
+ @pytest.mark.parametrize("value", ["2025-12-31", "yesterday", "0d"])
190
+ def test_parse_expires_refuses_the_past_and_nonsense(value):
191
+ with pytest.raises(ValueError):
192
+ parse_expires(value, now=T0)
193
+
194
+
195
+ def test_is_expired_fails_closed():
196
+ assert is_expired({"expires": 12345})
197
+ assert is_expired({"expires": "not a date"})
198
+ assert not is_expired({})
199
+
200
+
201
+ def test_limiter_bounds_its_keys_and_fits_the_protocol():
202
+ lim = SlidingWindowLimiter(max_per_window=1, max_keys=2, clock=lambda: 0.0)
203
+ assert lim.allow("a") and not lim.allow("a")
204
+ lim.allow("b"), lim.allow("c") # "a" is evicted
205
+ assert lim.allow("a")
206
+ assert isinstance(lim, RateLimiter)
207
+
208
+ class Redisish: # heed.security.RedisRateLimiter's shape
209
+ def allow(self, key):
210
+ return True
211
+
212
+ assert isinstance(Redisish(), RateLimiter)
213
+
214
+
215
+ def test_view_fields_are_reserved_too(links):
216
+ for name in ("protected", "active"):
217
+ with pytest.raises(ValueError, match="link's own"):
218
+ links.create({name: 1})
219
+
220
+
221
+ def test_a_hostile_unlock_value_is_refused_not_raised(links, clock):
222
+ rec = links.record(links.create(password=PW)["token"])
223
+ now = int(clock().timestamp())
224
+ assert not links.unlocked(rec, f"{now}.é", max_age_s=60)
225
+
226
+
227
+ def test_a_revoked_record_is_never_unlocked(links):
228
+ tok = links.create(password=PW)["token"]
229
+ links.revoke(tok)
230
+ assert not links.unlocked(links.record(tok), None, max_age_s=60)
231
+
232
+
233
+ @pytest.mark.parametrize(
234
+ "damage",
235
+ [{"password": 123}, {"created": 5}, {"expires": 7}, {"revoked": []}],
236
+ )
237
+ def test_a_record_with_mistyped_fields_is_missing_and_spares_the_list(links, damage):
238
+ good = links.create()["token"]
239
+ bad = links.create()["token"]
240
+ links.store[bad] = {**links.store[bad], **damage}
241
+ assert links.record(bad) is None and links.status(bad)[0] == "missing"
242
+ assert [r["token"] for r in links.list(include_inactive=True)] == [good]
243
+
244
+
245
+ def test_a_store_raising_oserror_closes_the_link(clock):
246
+ class Flaky(dict):
247
+ def __getitem__(self, k):
248
+ raise OSError("ssh dropped")
249
+
250
+ assert CapabilityLinks(Flaky(), clock=clock).record("A" * 22) is None
251
+
252
+
253
+ def test_record_is_a_copy(links):
254
+ tok = links.create(password=PW)["token"]
255
+ links.record(tok).pop("password")
256
+ assert links.record(tok)["password"]
257
+
258
+
259
+ @pytest.mark.parametrize("value", ["99999999d", "9999-12-31T23:59:59-05:00"])
260
+ def test_parse_expires_far_future_is_a_value_error(value):
261
+ with pytest.raises(ValueError, match="too far"):
262
+ parse_expires(value, now=T0)
263
+
264
+
265
+ @pytest.mark.parametrize("kw", [{"max_keys": 0}, {"max_per_window": 0}])
266
+ def test_limiter_refuses_settings_that_disable_it(kw):
267
+ with pytest.raises(ValueError):
268
+ SlidingWindowLimiter(**kw)