From 98652c4af079423ca1a84e792693529cc1a9219e Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 30 Sep 2026 14:10:53 +1000 Subject: [PATCH] fix(interop)!: reject double-dot interop segments (LAB-5906) The interop segment pattern admits `..` inside a segment (`a..b`), but the CachekitIO server rejects `..` anywhere in a key (protocol spec/cache-key-format.md#server-side-requirements, Traversal row), so such a key failed with 400 on every request while working on Redis and file backends. validate_segment now raises InteropError for a namespace or operation containing `..`, after the grammar check; decoration surfaces it as ConfigurationError, on every backend. A lone `.` stays valid. The interop guide states the rule. Re-vendors test-vectors/interop-mode.json 1.2.0 byte-for-byte from cachekit-io/protocol#94 (sha256 702613766d1b92bc3a337627a96b9aedc89abfeb4d9208c2bb00c9539a0a1f40; 35 key / 13 error vectors) and updates the sha and count pins. BREAKING CHANGE: an interop namespace or operation containing `..` now raises ConfigurationError at decoration time, on every backend. Rename the segment; its keys become a full cache miss. --- .secrets.baseline | 12 ++++---- docs/features/interop-mode.md | 4 +-- src/cachekit/interop.py | 12 +++++++- .../unit/protocol/fixtures/interop-mode.json | 30 +++++++++++++++++-- tests/unit/protocol/test_interop_decorator.py | 22 ++++++++++++++ tests/unit/protocol/test_interop_model.py | 23 ++++++++++++++ tests/unit/protocol/test_interop_vectors.py | 14 ++++----- 7 files changed, 99 insertions(+), 18 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 3f7bafc4..c7ddd53b 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -687,35 +687,35 @@ "filename": "tests/unit/protocol/fixtures/interop-mode.json", "hashed_secret": "be5aef15f4e2838807bbd3eae0273d810d3b06d9", "is_verified": false, - "line_number": 618 + "line_number": 630 }, { "type": "Hex High Entropy String", "filename": "tests/unit/protocol/fixtures/interop-mode.json", "hashed_secret": "e10287fe94b240c3f1b80108a6f38ba327656d1a", "is_verified": false, - "line_number": 649 + "line_number": 661 }, { "type": "Hex High Entropy String", "filename": "tests/unit/protocol/fixtures/interop-mode.json", "hashed_secret": "22451150856583e1d666836db8b8f8069e5180bb", "is_verified": false, - "line_number": 751 + "line_number": 777 }, { "type": "Hex High Entropy String", "filename": "tests/unit/protocol/fixtures/interop-mode.json", "hashed_secret": "43f8a5e00c7a0964cdf90022b9eb2cab0b0f96ca", "is_verified": false, - "line_number": 760 + "line_number": 786 }, { "type": "Hex High Entropy String", "filename": "tests/unit/protocol/fixtures/interop-mode.json", "hashed_secret": "f0890edf4761a1bc77aa54ed51547f2f8bc2bcb3", "is_verified": false, - "line_number": 767 + "line_number": 793 } ], "tests/unit/protocol/fixtures/wire-format.json": [ @@ -874,5 +874,5 @@ } ] }, - "generated_at": "2026-09-29T11:52:34Z" + "generated_at": "2026-09-30T04:10:48Z" } diff --git a/docs/features/interop-mode.md b/docs/features/interop-mode.md index bc521af0..15a59c06 100644 --- a/docs/features/interop-mode.md +++ b/docs/features/interop-mode.md @@ -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. @@ -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) | diff --git a/src/cachekit/interop.py b/src/cachekit/interop.py index 28a9c6b0..9083909d 100644 --- a/src/cachekit/interop.py +++ b/src/cachekit/interop.py @@ -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 @@ -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 " diff --git a/tests/unit/protocol/fixtures/interop-mode.json b/tests/unit/protocol/fixtures/interop-mode.json index 9da16545..5748c4ff 100644 --- a/tests/unit/protocol/fixtures/interop-mode.json +++ b/tests/unit/protocol/fixtures/interop-mode.json @@ -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": { @@ -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": [ @@ -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": [ diff --git a/tests/unit/protocol/test_interop_decorator.py b/tests/unit/protocol/test_interop_decorator.py index 9a5c2c94..fb681d71 100644 --- a/tests/unit/protocol/test_interop_decorator.py +++ b/tests/unit/protocol/test_interop_decorator.py @@ -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.""" diff --git a/tests/unit/protocol/test_interop_model.py b/tests/unit/protocol/test_interop_model.py index 33b3cafa..9297e1af 100644 --- a/tests/unit/protocol/test_interop_model.py +++ b/tests/unit/protocol/test_interop_model.py @@ -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" diff --git a/tests/unit/protocol/test_interop_vectors.py b/tests/unit/protocol/test_interop_vectors.py index ce939820..af7f799c 100644 --- a/tests/unit/protocol/test_interop_vectors.py +++ b/tests/unit/protocol/test_interop_vectors.py @@ -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 @@ -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: