Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions docs/features/interop-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Treat operation names like queue names or topic names: a **cross-team contract**

The contract for one operation is the operation name **plus** the effective argument list (arity, order, types):

- `namespace` and `operation` must match `^[a-z0-9][a-z0-9._-]{0,63}$` (lowercase only — enforced loudly at decoration time, never silently normalized). `ns` and `nsapi` are reserved as namespaces (operations, and namespaces such as `nsx`, are unaffected), because the CachekitIO server parses a key starting `ns:` or `nsapi:` as namespace-prefixed ([cache-key-format.md → Server-Side Requirements](https://github.com/cachekit-io/protocol/blob/main/spec/cache-key-format.md#server-side-requirements)).
- `namespace` and `operation` must match `^[a-z0-9][a-z0-9._-]{0,63}$` (lowercase only — enforced loudly at decoration time, never silently normalized). `ns` and `nsapi` are reserved as namespaces (operations, and namespaces such as `nsx`, are unaffected), because the CachekitIO server parses a key starting `ns:` or `nsapi:` as namespace-prefixed ([cache-key-format.md → Server-Side Requirements](https://github.com/cachekit-io/protocol/blob/main/spec/cache-key-format.md#server-side-requirements)). Neither segment may contain `..` (`a..b` raises; a lone `.`, as in `app.v1`, is fine), because the server rejects `..` anywhere in a key. Both rules apply on every backend, so a namespace valid on one backend is valid on all.
- A `str` subclass is checked and keyed as its plain `str` value, so a `StrEnum` or `(str, Enum)` member `USERS = "users"` is the namespace `users`. Earlier releases put a `(str, Enum)` member's formatted name into the key on Python 3.11 and later (`NS.USERS:get_user:…`, a key no other SDK derives). Upgrading moves those functions to the correct key. The old entries are not deleted: they retire only by TTL (never, if none was set). To erase them on Redis, `SCAN` for the old prefix (here `NS.USERS:get_user:*`) and `UNLINK` the matches.
- Named arguments bind to their declared positions and **introspectable defaults are applied**: `get_user(42)`, `get_user(user_id=42)` and `get_user(42, include_profile=False)` all produce the same key.
- Arguments must fit the closed interop data model (int in `[-2^63, 2^64-1]`, float, str, bytes, bool, None, list/tuple, dict with str keys, set, tz-aware datetime, UUID; Python conveniences: Enum → value, Path → POSIX string, Decimal → string). Anything else raises `InteropError` **at call time** — interop mode never silently degrades to uncached execution.
Expand Down Expand Up @@ -115,7 +115,7 @@ One thing no guardrail can catch: two *binders* of the same `(namespace, operati

| Situation | Behavior |
| :--- | :--- |
| Missing/invalid `namespace` or `operation`, including the reserved namespaces `ns` and `nsapi` | `ConfigurationError` at decoration time |
| Missing/invalid `namespace` or `operation`, including the reserved namespaces `ns` and `nsapi` and a segment containing `..` | `ConfigurationError` at decoration time |
| `interop=` combined with `key=`, `fast_mode`, `backend=None` (L1-only), or a non-default serializer | `ConfigurationError` at decoration time |
| Explicit deployment UUID not in canonical lowercase-hyphenated form | `ConfigurationError` at decoration time |
| Backend with a wire-level key prefix (e.g. Memcached `key_prefix`) | `ConfigurationError` — checked at decoration **and re-checked per call**, including `invalidate_cache()` (a prefixed key is invisible to other SDKs and would escape the encryption AAD binding) |
Expand Down
12 changes: 11 additions & 1 deletion src/cachekit/interop.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,8 @@ def validate_segment(name: str, segment: object) -> str:

Full-string match against ``^[a-z0-9][a-z0-9._-]{0,63}$`` — never silently
normalized. re.fullmatch, not re.match: $ accepts a trailing newline.
A namespace must additionally not be one of :data:`RESERVED_NAMESPACES`.
Neither segment may contain ``..``, and a namespace must additionally not
be one of :data:`RESERVED_NAMESPACES`.

A ``str`` subclass is checked and returned as its exact ``str`` value, so
callers must render the returned value, not the argument. The subclass's own
Expand All @@ -304,6 +305,15 @@ def validate_segment(name: str, segment: object) -> str:
f"invalid interop {name} {value!r}: must full-string match ^[a-z0-9][a-z0-9._-]{{0,63}}$ "
f"(lowercase ASCII letters, digits, '.', '_', '-'; 1-64 chars)"
)
# SEGMENT_RE admits `..`, but the server rejects `..` anywhere in a key
# (protocol spec/cache-key-format.md "Server-Side Requirements", Traversal
# row). The `:` delimiters separate the segments and the hash is hex, so any
# `..` in a key lies inside one segment.
if ".." in value:
raise InteropError(
f"invalid interop {name} {value!r}: must not contain '..', because the CachekitIO server "
f"rejects '..' anywhere in a key"
)
if name == "namespace" and value in RESERVED_NAMESPACES:
raise InteropError(
f"invalid interop namespace {value!r}: reserved, because the CachekitIO server parses a key "
Expand Down
30 changes: 28 additions & 2 deletions tests/unit/protocol/fixtures/interop-mode.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"version": "1.1.0",
"version": "1.2.0",
"spec": "spec/interop-mode.md",
"generator": "tools/interop-reference.py (CPython stdlib)",
"cross_checked_by": "tools/interop-crosscheck.mjs (independent encoder + @noble/hashes blake2b + WebCrypto HKDF/AES-GCM)",
"hash_algorithm": "blake2b-256 (digest_size=32, unkeyed) over canonical MessagePack of the flat argument array",
"key_format": "{namespace}:{operation}:{args_hash}",
"segment_pattern": "^[a-z0-9][a-z0-9._-]{0,63}$",
"segment_pattern_note": "Full-string match REQUIRED (Python: re.fullmatch, not re.match \u2014 $ matches before a trailing newline). namespace additionally MUST NOT be exactly 'ns' or 'nsapi' (reserved: the server parses those key prefixes). The reservation is namespace-only; operation has no reserved values.",
"segment_pattern_note": "Full-string match REQUIRED (Python: re.fullmatch, not re.match \u2014 $ matches before a trailing newline). namespace additionally MUST NOT be exactly 'ns' or 'nsapi' (reserved: the server parses those key prefixes). The reservation is namespace-only; operation has no reserved values. Neither segment may contain '..' (the pattern admits it; the server rejects '..' anywhere in a key). A lone '.' stays valid.",
"width_coverage_note": "All *16 header boundaries (uint/int widths, str8->str16, bin8->bin16, fixarray->array16, fixmap->map16, including the root argument array) are pinned by vectors. The *32 tier (str32/bin32/array32/map32, >=64 KiB or >=65536 elements) is normative and implemented by both tools but untested-by-design: fixture blobs that size would bloat the file without exercising different logic (same length-prefix code path, wider field).",
"error_vectors_note": "The 'error' field is a human-readable reason for maintainers. Conformance means the input MUST be rejected with an error; the message text is not normative.",
"tagged_json": {
Expand Down Expand Up @@ -605,6 +605,18 @@
"canonical_args_hex": "9101",
"args_hash": "405f09a3617bcc1425ea95b9840d9c2713e3ecebd5a3227abc599317b732e21a",
"expected_key": "nsapix:nsapi:405f09a3617bcc1425ea95b9840d9c2713e3ecebd5a3227abc599317b732e21a"
},
{
"name": "lone_dots_stay_valid",
"description": "Only '..' is forbidden: a lone trailing '.' (namespace 'app.') and non-adjacent dots (operation 'users.fetch.by_id') stay valid",
"namespace": "app.",
"operation": "users.fetch.by_id",
"args": [
1
],
"canonical_args_hex": "9101",
"args_hash": "405f09a3617bcc1425ea95b9840d9c2713e3ecebd5a3227abc599317b732e21a",
"expected_key": "app.:users.fetch.by_id:405f09a3617bcc1425ea95b9840d9c2713e3ecebd5a3227abc599317b732e21a"
}
],
"value_vectors": [
Expand Down Expand Up @@ -738,6 +750,20 @@
"operation": "users.fetch_by_id",
"args": [],
"error": "namespace 'nsapi' is reserved: the server parses a key starting 'nsapi:' as namespace-prefixed (rejected whatever the operation, including one the server would 400 on for its '.')"
},
{
"name": "reject_double_dot_namespace",
"namespace": "a..b",
"operation": "get_user",
"args": [],
"error": "namespace must not contain '..': the pattern admits it, but the server rejects '..' anywhere in a key"
},
{
"name": "reject_double_dot_operation",
"namespace": "users",
"operation": "x..",
"args": [],
"error": "operation must not contain '..': the pattern admits it, but the server rejects '..' anywhere in a key (here at the end of the segment, which a check that skips the last pair misses)"
}
],
"aad_vectors": [
Expand Down
22 changes: 22 additions & 0 deletions tests/unit/protocol/test_interop_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,28 @@ def test_reserved_namespace_rejected_at_decoration(self, backend: DictBackend, r
def f(x: int):
return x

@pytest.mark.parametrize(("operation", "namespace"), [("get_user", "a..b"), ("x..y", "users")])
def test_double_dot_segment_rejected_at_decoration(self, backend: DictBackend, operation: str, namespace: str):
# The server rejects '..' anywhere in a key, so the key would fail on
# every CachekitIO request; fail before any call, on every backend.
with pytest.raises(ConfigurationError, match=r"must not contain '\.\.'"):

@_decorate(backend, interop=operation, namespace=namespace)
def f(x: int):
return x

def test_lone_dots_accepted(self, backend: DictBackend):
"""Only '..' is forbidden: dotted segments decorate and write the
byte-pinned vector key."""
vector = KEY_VECTORS["lone_dots_stay_valid"]

@_decorate(backend, interop=vector["operation"], namespace=vector["namespace"])
def f(x: int):
return x

f(*vector["args"])
assert list(backend.store) == [vector["expected_key"]]

def test_reservation_scope_accepted(self, backend: DictBackend):
"""The reservation is exact-match and namespace-only: operation nsapi in
namespace nsapix decorates and writes the byte-pinned vector key."""
Expand Down
23 changes: 23 additions & 0 deletions tests/unit/protocol/test_interop_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,29 @@ def test_reservation_is_exact_match_and_namespace_only(self, operation: str, nam
assert generate_interop_key(namespace, operation, [1]).startswith(f"{namespace}:{operation}:")


class TestDoubleDotSegments:
"""Neither segment may contain ``..``: the segment pattern admits it, but the
server rejects ``..`` anywhere in a key. A lone ``.`` stays valid."""

@pytest.mark.parametrize(
("operation", "namespace"),
[("get_user", "a..b"), ("x..y", "users"), ("users.v1..beta", "users"), ("get_user", "a...b"), ("x..", "users")],
)
def test_double_dot_rejected_by_keygen_and_config(self, operation: str, namespace: str):
with pytest.raises(InteropError, match=r"must not contain '\.\.'"):
generate_interop_key(namespace, operation, [1])
with pytest.raises(InteropError, match=r"must not contain '\.\.'"):
validate_interop_config(operation, namespace)

@pytest.mark.parametrize(
("operation", "namespace"),
[("users.fetch.by_id", "app.v1"), ("get.", "users"), ("a.b.c", "x.y")],
)
def test_lone_dots_accepted(self, operation: str, namespace: str):
assert validate_interop_config(operation, namespace) == (operation, namespace)
assert generate_interop_key(namespace, operation, [1]).startswith(f"{namespace}:{operation}:")


class NS(str, Enum):
USERS = "users"

Expand Down
14 changes: 7 additions & 7 deletions tests/unit/protocol/test_interop_vectors.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
"""Byte-verification of interop mode against the protocol test vectors.

Fixture: tests/unit/protocol/fixtures/interop-mode.json, vendored from
cachekit-io/protocol test-vectors/interop-mode.json 1.1.0
(https://github.com/cachekit-io/protocol/pull/78)
(sha256 9b1855851d888c479e37a8fff9e9bbe5738737a9a408e749d9126c7678b9e7bc).
cachekit-io/protocol test-vectors/interop-mode.json 1.2.0
(https://github.com/cachekit-io/protocol/pull/94)
(sha256 702613766d1b92bc3a337627a96b9aedc89abfeb4d9208c2bb00c9539a0a1f40).
Regenerate ONLY by re-copying from the protocol repo — never by hand.

Every group is exercised through the SDK's own implementation:
- 34 key vectors: canonical argument bytes, args hash, and full key
- 35 key vectors: canonical argument bytes, args hash, and full key
- 4 value vectors: canonical plain-MessagePack value bytes (and decode round-trip)
- 11 error vectors: inputs that MUST be rejected
- 13 error vectors: inputs that MUST be rejected
- 1 AAD vector: the REAL EncryptionWrapper AAD builder over an interop key
- 1 encryption vector: HKDF-SHA256 key derivation + AES-256-GCM decrypt through
the REAL Rust encryption stack (cross-SDK decryption capability, not just
Expand Down Expand Up @@ -37,13 +37,13 @@
)

FIXTURE_PATH = Path(__file__).parent / "fixtures" / "interop-mode.json"
FIXTURE_SHA256 = "9b1855851d888c479e37a8fff9e9bbe5738737a9a408e749d9126c7678b9e7bc" # pragma: allowlist secret
FIXTURE_SHA256 = "702613766d1b92bc3a337627a96b9aedc89abfeb4d9208c2bb00c9539a0a1f40" # pragma: allowlist secret

VECTORS = json.loads(FIXTURE_PATH.read_text(encoding="utf-8"))

# The counts below are part of the conformance claim: a fixture update that
# adds or removes vectors must be a conscious change, not a silent drift.
EXPECTED_COUNTS = {"key_vectors": 34, "value_vectors": 4, "error_vectors": 11, "aad_vectors": 1, "encryption_vectors": 1}
EXPECTED_COUNTS = {"key_vectors": 35, "value_vectors": 4, "error_vectors": 13, "aad_vectors": 1, "encryption_vectors": 1}


class _TaggedSet:
Expand Down
Loading