From abb37824af14d0ef1180753d4f0788c4dd745633 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 30 Sep 2026 13:52:28 +1000 Subject: [PATCH 1/3] fix(interop)!: forbid double-dot inside interop segments (LAB-5906) The segment pattern admits `..` inside a segment (`a..b`, `users.v1..beta`), but the server rejects `..` anywhere in a key (cache-key-format.md, Server-Side Requirements, Traversal row). Every SDK therefore accepted such a segment and minted a key that fails with 400 on every CachekitIO request, while the same key works on Redis and file backends. Interop keys are portable, so the grammar forbids it on every backend, the same rule the ns/nsapi reservation follows. The pattern stays a plain regex without lookahead, so every SDK can apply it as written; the ban is a separate substring check. A `..` cannot span the `:` delimiter because a segment cannot start with `.`, so a per-segment check covers the whole key. Fixture 1.2.0 adds reject_double_dot_namespace (a..b), reject_double_dot_operation (x..y) and the key vector lone_dots_stay_valid (app.v1 / users.fetch.by_id): before it, no key vector had a `.` in a segment, so an SDK that rejected every `.` passed the suite. BREAKING CHANGE: an interop namespace or operation containing `..` now raises at decoration / registration time, on every backend. Rename the segment. --- changelog.d/20260930_lab-5906.md | 24 ++++++++++++++++ spec/interop-mode.md | 25 +++++++++++------ test-vectors/interop-mode.json | 30 ++++++++++++++++++-- tools/interop-crosscheck.mjs | 8 ++++-- tools/interop-reference.py | 48 ++++++++++++++++++++++++++++++-- 5 files changed, 119 insertions(+), 16 deletions(-) create mode 100644 changelog.d/20260930_lab-5906.md diff --git a/changelog.d/20260930_lab-5906.md b/changelog.d/20260930_lab-5906.md new file mode 100644 index 0000000..5346dff --- /dev/null +++ b/changelog.d/20260930_lab-5906.md @@ -0,0 +1,24 @@ +### Interop mode — `..` is forbidden inside a segment (LAB-5906) + +- [`spec/interop-mode.md` → Segment grammar](spec/interop-mode.md#segment-grammar): + a segment (`namespace` or `operation`) MUST NOT contain `..`. The segment pattern + admits it (`a..b`, `users.v1..beta`), but the server rejects `..` anywhere in a key + ([cache-key-format.md → Server-Side Requirements](spec/cache-key-format.md#server-side-requirements), + the Traversal row), so every SDK minted a key that CachekitIO answered with `400` on + every request, while the same key worked on Redis and file backends. The pattern + itself is unchanged and stays a plain regex; the rule is a separate substring check. + A lone `.` stays valid. **Breaking for any deployment whose interop namespace or + operation contains `..`, on any backend:** it now raises at decoration / + registration time; migrate by renaming the segment (a full cache miss for the keys + it names). +- [`test-vectors/interop-mode.json`](test-vectors/interop-mode.json) 1.2.0: two error + vectors (`reject_double_dot_namespace`, namespace `a..b`; `reject_double_dot_operation`, + operation `x..y`) and one key vector (`lone_dots_stay_valid`: namespace `app.v1`, + operation `users.fetch.by_id`), the first key vector with a `.` in a segment. Counts: + 35 key, 13 error. +- The status banner and SaaS Considerations no longer name `..` as an exception to the + grammar being a subset of what the server accepts. +- `tools/interop-reference.py` rejects a `..` segment, and its self-check asserts that + both new error vectors match `segment_pattern`, so they exercise the new rule rather + than the pattern. `tools/interop-crosscheck.mjs` hard-codes the rule rather than + reading it from the fixture, as it does for the reserved namespaces. diff --git a/spec/interop-mode.md b/spec/interop-mode.md index 86db3a0..3c3b6fd 100644 --- a/spec/interop-mode.md +++ b/spec/interop-mode.md @@ -13,8 +13,7 @@ > each registry or the [SDK feature matrix](../sdk-feature-matrix.md#compliance-status) for current versions. > Server-side: the CachekitIO validator accepts interop-format keys > (`{namespace}:{operation}:{args_hash}` scopes to the `default` namespace; -> see [cache-key-format.md → Server-Side Requirements](cache-key-format.md#server-side-requirements)), -> except a key with `..` in a segment ([SaaS Considerations](#saas-considerations)). +> see [cache-key-format.md → Server-Side Requirements](cache-key-format.md#server-side-requirements)). > Design discussion: [Issue #1](https://github.com/cachekit-io/protocol/issues/1) · > Test vectors: [`test-vectors/interop-mode.json`](../test-vectors/interop-mode.json) · > Reference implementation: [`tools/interop-reference.py`](../tools/interop-reference.py) @@ -125,6 +124,16 @@ reservation is exact-match and namespace-only — `nsapix` is a valid namespace, and `nsapi` are valid operations. The `reject_reserved_namespace_*` error vectors and the `reservation_scope` key vector pin it. +A segment (`namespace` or `operation`) additionally MUST NOT contain `..`: the server +rejects `..` anywhere in a key +([cache-key-format.md → Server-Side Requirements](cache-key-format.md#server-side-requirements), +the Traversal row), so such a key would fail on every CachekitIO request. The pattern +admits `..`, so this is a separate check beside it; the pattern stays a plain regex +without lookahead. SDKs reject a `..` segment like a reserved namespace: at decoration / +registration time, on every backend. A lone `.` stays valid, and so do dots that are not +adjacent (`app.v1`, `users.fetch.by_id`). The `reject_double_dot_*` error vectors and the +`lone_dots_stay_valid` key vector pin it. + > [!WARNING] > **Full-string means full-string.** In Python, `re.match` with a `$` anchor still > accepts a trailing newline (`"users\n"` passes) — use `re.fullmatch`. A segment @@ -399,10 +408,8 @@ isolation comes from authentication, not key parsing). > accepts interop-format keys; see > [cache-key-format.md → Server-Side Requirements](cache-key-format.md#server-side-requirements). > The interop segment grammar (lowercase, no `:` beyond the two delimiters, no `/`, -> max 194 chars, no reserved namespace) is deliberately a subset of what the -> security-only validator accepts, with one known exception: the grammar admits `..` -> inside a segment, and the validator rejects `..` anywhere in a key (the Traversal -> row), so such a key fails with `400`. +> no `..`, max 194 chars, no reserved namespace) is deliberately a subset of what the +> security-only validator accepts. --- @@ -439,7 +446,7 @@ const getUser = cache.wrap(fetchUser, { An SDK implementation of interop mode MUST: 1. Require explicit `namespace` and `operation`, validated against the segment grammar - (including the reserved namespaces `ns` and `nsapi`). + (including the reserved namespaces `ns` and `nsapi`, and no `..` in either segment). 2. Build the canonical argument array per the binding rules (named→positional, defaults applied where introspectable). 3. Normalize and encode per this spec; reject out-of-model values with an error. @@ -561,11 +568,11 @@ not re-litigated by accident. | Group | Count | Verifies | | :--- | :---: | :--- | -| `key_vectors` | 34 | Canonical argument bytes (exact hex), args hash, full key — the `2.0`≡`2` collapse pair, supplementary-plane key sorting, heterogeneous and mixed-sign sets (byte order ≠ natural order), set dedupe (`{2, 2.0}` → `[2]`), datetime edge cases incl. pre-epoch, both collapse-range endpoints, every `*16`-tier width boundary (uint/int ladders, str/bin/array/map headers, root array16), and the reservation's exact-match, namespace-only scope (`nsapix` namespace, `nsapi` operation) | +| `key_vectors` | 35 | Canonical argument bytes (exact hex), args hash, full key — the `2.0`≡`2` collapse pair, supplementary-plane key sorting, heterogeneous and mixed-sign sets (byte order ≠ natural order), set dedupe (`{2, 2.0}` → `[2]`), datetime edge cases incl. pre-epoch, both collapse-range endpoints, every `*16`-tier width boundary (uint/int ladders, str/bin/array/map headers, root array16), the reservation's exact-match, namespace-only scope (`nsapix` namespace, `nsapi` operation), and lone dots, which stay valid (`app.v1` namespace, `users.fetch.by_id` operation) | | `value_vectors` | 4 | Plain-MessagePack value bytes (exact hex), float64 preservation in the value profile, temporal sentinel maps | | `aad_vectors` | 1 | AAD v0x03 bytes over an interop key (`format=msgpack`, `compressed=False`) | | `encryption_vectors` | 1 | Full HKDF-SHA256 → AES-256-GCM round-trip over plain-msgpack plaintext with the interop AAD (fixed nonce; decrypt-verified) | -| `error_vectors` | 11 | Inputs that MUST be rejected (NaN, +Inf and −Inf as independent vectors, int overflow/underflow, naive datetime, bad segments incl. trailing newline, the reserved namespaces `ns` and `nsapi`). The `error` text is a maintainer note, not a normative message | +| `error_vectors` | 13 | Inputs that MUST be rejected (NaN, +Inf and −Inf as independent vectors, int overflow/underflow, naive datetime, bad segments incl. trailing newline, the reserved namespaces `ns` and `nsapi`, `..` in either segment). The `error` text is a maintainer note, not a normative message | [`test-vectors/decode-bounds.json`](../test-vectors/decode-bounds.json) pins the [Decode bounds](#decode-bounds); `tools/decode-bounds-reference.py verify` checks it, diff --git a/test-vectors/interop-mode.json b/test-vectors/interop-mode.json index 9da1654..0f1eae9 100644 --- a/test-vectors/interop-mode.json +++ b/test-vectors/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 '.' (namespace 'app.v1') and non-adjacent dots (operation 'users.fetch.by_id') stay valid", + "namespace": "app.v1", + "operation": "users.fetch.by_id", + "args": [ + 1 + ], + "canonical_args_hex": "9101", + "args_hash": "405f09a3617bcc1425ea95b9840d9c2713e3ecebd5a3227abc599317b732e21a", + "expected_key": "app.v1: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..y", + "args": [], + "error": "operation must not contain '..': the pattern admits it, but the server rejects '..' anywhere in a key" } ], "aad_vectors": [ diff --git a/tools/interop-crosscheck.mjs b/tools/interop-crosscheck.mjs index 7731396..504675f 100644 --- a/tools/interop-crosscheck.mjs +++ b/tools/interop-crosscheck.mjs @@ -270,12 +270,14 @@ const vectorsPath = process.argv[2] ?? join(here, "..", "test-vectors", "interop const doc = JSON.parse(readFileSync(vectorsPath, "utf8")); // Segment grammar: the fixture's pattern governs both segments; the reserved -// namespaces are hard-coded from the spec, not read from the fixture, so the -// reservation is checked by a second implementation rather than echoed back. +// namespaces and the `..` ban are hard-coded from the spec, not read from the +// fixture, so both rules are checked by a second implementation rather than +// echoed back. const segmentRe = new RegExp(doc.segment_pattern, "u"); const RESERVED_NAMESPACES = new Set(["ns", "nsapi"]); +const segmentValid = (segment) => segmentRe.test(segment) && !segment.includes(".."); const segmentsValid = (namespace, operation) => - segmentRe.test(namespace) && segmentRe.test(operation) && !RESERVED_NAMESPACES.has(namespace); + segmentValid(namespace) && segmentValid(operation) && !RESERVED_NAMESPACES.has(namespace); let failures = 0; const check = (name, kind, expected, actual) => { diff --git a/tools/interop-reference.py b/tools/interop-reference.py index 529ac2e..367219f 100644 --- a/tools/interop-reference.py +++ b/tools/interop-reference.py @@ -46,6 +46,12 @@ # rejected. Namespace-only and exact-match — `ns` as an operation, or `nsapix` as # a namespace, cannot form either prefix. RESERVED_NAMESPACES = frozenset({"ns", "nsapi"}) +# The grammar admits `..` inside a segment, but the server rejects `..` anywhere +# in a key (spec/cache-key-format.md#server-side-requirements, Traversal), so a +# segment containing it would mint a key that fails on every request. A `..` +# cannot span the `:` delimiter (a segment cannot start with `.`), so checking +# each segment covers the whole key. +FORBIDDEN_SUBSTRING = ".." UINT64_MAX = 2**64 - 1 INT64_MIN = -(2**63) @@ -251,6 +257,10 @@ def interop_key(namespace: str, operation: str, args: list | tuple) -> str: raise InteropError( f"invalid interop {name} {seg!r}: must full-string match ^[a-z0-9][a-z0-9._-]{{0,63}}$" ) + if FORBIDDEN_SUBSTRING in seg: + raise InteropError( + f"invalid interop {name} {seg!r}: must not contain '..' (the server rejects '..' anywhere in a key)" + ) if namespace in RESERVED_NAMESPACES: raise InteropError( f"invalid interop namespace {namespace!r}: 'ns' and 'nsapi' are reserved " @@ -606,6 +616,16 @@ def tagged_args(raw: list) -> list: "operation": "nsapi", "args": [1], }, + { + "name": "lone_dots_stay_valid", + "description": ( + "Only '..' is forbidden: a lone '.' (namespace 'app.v1') and non-adjacent dots " + "(operation 'users.fetch.by_id') stay valid" + ), + "namespace": "app.v1", + "operation": "users.fetch.by_id", + "args": [1], + }, ] VALUE_VECTORS: list[dict] = [ @@ -699,6 +719,20 @@ def tagged_args(raw: list) -> list: "(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..y", + "args": [], + "error": "operation must not contain '..': the pattern admits it, but the server rejects '..' anywhere in a key", + }, ] @@ -741,7 +775,7 @@ def _build() -> dict: aad = aad_v3(ENC_TENANT_ID, single_int["expected_key"]) return { - "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)", @@ -751,7 +785,9 @@ def _build() -> dict: "segment_pattern_note": ( "Full-string match REQUIRED (Python: re.fullmatch, not re.match — $ 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." + "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, " @@ -852,6 +888,14 @@ def _self_check(built: dict) -> None: else: raise AssertionError("lone surrogate must be rejected, not encoded") + # The '..' vectors must pass the pattern, or they would prove the grammar + # rather than the extra rule an implementation has to add beside it. + for name in ("reject_double_dot_namespace", "reject_double_dot_operation"): + ev = next(e for e in ERROR_VECTORS if e["name"] == name) + assert SEGMENT_RE.fullmatch(ev["namespace"]) and SEGMENT_RE.fullmatch(ev["operation"]), ( + f"{name} must match segment_pattern so it exercises the '..' rule" + ) + for ev in ERROR_VECTORS: try: if "namespace" in ev: From 701ceaba3c74cd0ea1ba90895c8fb472a868fe6b Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 30 Sep 2026 14:00:03 +1000 Subject: [PATCH 2/3] fix(interop): pin a trailing double-dot and a trailing lone dot (LAB-5906) reject_double_dot_operation now puts the `..` at the end of the segment (`x..`): a byte loop that skips the last pair still rejects `a..b` and `x..y`, but accepts `abc..`. The namespace vector keeps the mid-segment case. lone_dots_stay_valid now uses namespace `app.`: an implementation that splits on `.` and rejects empty labels, or rejects a trailing `.`, passed every vector before, although the grammar allows a trailing `.`. The comments now give the right reason a per-segment check covers the key: the `:` delimiters separate the segments and the hash is hex, so any `..` lies inside one segment. --- changelog.d/20260930_lab-5906.md | 6 +++--- spec/interop-mode.md | 7 ++++--- test-vectors/interop-mode.json | 10 +++++----- tools/interop-reference.py | 17 ++++++++++------- 4 files changed, 22 insertions(+), 18 deletions(-) diff --git a/changelog.d/20260930_lab-5906.md b/changelog.d/20260930_lab-5906.md index 5346dff..f7706b0 100644 --- a/changelog.d/20260930_lab-5906.md +++ b/changelog.d/20260930_lab-5906.md @@ -13,9 +13,9 @@ it names). - [`test-vectors/interop-mode.json`](test-vectors/interop-mode.json) 1.2.0: two error vectors (`reject_double_dot_namespace`, namespace `a..b`; `reject_double_dot_operation`, - operation `x..y`) and one key vector (`lone_dots_stay_valid`: namespace `app.v1`, - operation `users.fetch.by_id`), the first key vector with a `.` in a segment. Counts: - 35 key, 13 error. + operation `x..`, with the `..` at the end of the segment) and one key vector + (`lone_dots_stay_valid`: namespace `app.`, operation `users.fetch.by_id`), the first key + vector with a `.` in a segment. Counts: 35 key, 13 error. - The status banner and SaaS Considerations no longer name `..` as an exception to the grammar being a subset of what the server accepts. - `tools/interop-reference.py` rejects a `..` segment, and its self-check asserts that diff --git a/spec/interop-mode.md b/spec/interop-mode.md index 3c3b6fd..7c85f1f 100644 --- a/spec/interop-mode.md +++ b/spec/interop-mode.md @@ -130,8 +130,9 @@ rejects `..` anywhere in a key the Traversal row), so such a key would fail on every CachekitIO request. The pattern admits `..`, so this is a separate check beside it; the pattern stays a plain regex without lookahead. SDKs reject a `..` segment like a reserved namespace: at decoration / -registration time, on every backend. A lone `.` stays valid, and so do dots that are not -adjacent (`app.v1`, `users.fetch.by_id`). The `reject_double_dot_*` error vectors and the +registration time, on every backend. A lone `.` stays valid, including at the end of a +segment, and so do dots that are not adjacent (`app.`, `app.v1`, `users.fetch.by_id`). The +`reject_double_dot_*` error vectors (`..` inside and at the end of a segment) and the `lone_dots_stay_valid` key vector pin it. > [!WARNING] @@ -568,7 +569,7 @@ not re-litigated by accident. | Group | Count | Verifies | | :--- | :---: | :--- | -| `key_vectors` | 35 | Canonical argument bytes (exact hex), args hash, full key — the `2.0`≡`2` collapse pair, supplementary-plane key sorting, heterogeneous and mixed-sign sets (byte order ≠ natural order), set dedupe (`{2, 2.0}` → `[2]`), datetime edge cases incl. pre-epoch, both collapse-range endpoints, every `*16`-tier width boundary (uint/int ladders, str/bin/array/map headers, root array16), the reservation's exact-match, namespace-only scope (`nsapix` namespace, `nsapi` operation), and lone dots, which stay valid (`app.v1` namespace, `users.fetch.by_id` operation) | +| `key_vectors` | 35 | Canonical argument bytes (exact hex), args hash, full key — the `2.0`≡`2` collapse pair, supplementary-plane key sorting, heterogeneous and mixed-sign sets (byte order ≠ natural order), set dedupe (`{2, 2.0}` → `[2]`), datetime edge cases incl. pre-epoch, both collapse-range endpoints, every `*16`-tier width boundary (uint/int ladders, str/bin/array/map headers, root array16), the reservation's exact-match, namespace-only scope (`nsapix` namespace, `nsapi` operation), and lone dots, which stay valid (`app.` namespace, `users.fetch.by_id` operation) | | `value_vectors` | 4 | Plain-MessagePack value bytes (exact hex), float64 preservation in the value profile, temporal sentinel maps | | `aad_vectors` | 1 | AAD v0x03 bytes over an interop key (`format=msgpack`, `compressed=False`) | | `encryption_vectors` | 1 | Full HKDF-SHA256 → AES-256-GCM round-trip over plain-msgpack plaintext with the interop AAD (fixed nonce; decrypt-verified) | diff --git a/test-vectors/interop-mode.json b/test-vectors/interop-mode.json index 0f1eae9..5748c4f 100644 --- a/test-vectors/interop-mode.json +++ b/test-vectors/interop-mode.json @@ -608,15 +608,15 @@ }, { "name": "lone_dots_stay_valid", - "description": "Only '..' is forbidden: a lone '.' (namespace 'app.v1') and non-adjacent dots (operation 'users.fetch.by_id') stay valid", - "namespace": "app.v1", + "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.v1:users.fetch.by_id:405f09a3617bcc1425ea95b9840d9c2713e3ecebd5a3227abc599317b732e21a" + "expected_key": "app.:users.fetch.by_id:405f09a3617bcc1425ea95b9840d9c2713e3ecebd5a3227abc599317b732e21a" } ], "value_vectors": [ @@ -761,9 +761,9 @@ { "name": "reject_double_dot_operation", "namespace": "users", - "operation": "x..y", + "operation": "x..", "args": [], - "error": "operation must not contain '..': the pattern admits it, but the server rejects '..' anywhere in a key" + "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/tools/interop-reference.py b/tools/interop-reference.py index 367219f..efc8f4d 100644 --- a/tools/interop-reference.py +++ b/tools/interop-reference.py @@ -48,9 +48,9 @@ RESERVED_NAMESPACES = frozenset({"ns", "nsapi"}) # The grammar admits `..` inside a segment, but the server rejects `..` anywhere # in a key (spec/cache-key-format.md#server-side-requirements, Traversal), so a -# segment containing it would mint a key that fails on every request. A `..` -# cannot span the `:` delimiter (a segment cannot start with `.`), so checking -# each segment covers the whole key. +# segment containing it would mint a key that fails on every request. The `:` +# delimiters separate the segments and the hash is hex, so any `..` in the key +# lies inside one segment, and checking each segment covers the whole key. FORBIDDEN_SUBSTRING = ".." UINT64_MAX = 2**64 - 1 @@ -619,10 +619,10 @@ def tagged_args(raw: list) -> list: { "name": "lone_dots_stay_valid", "description": ( - "Only '..' is forbidden: a lone '.' (namespace 'app.v1') and non-adjacent dots " + "Only '..' is forbidden: a lone trailing '.' (namespace 'app.') and non-adjacent dots " "(operation 'users.fetch.by_id') stay valid" ), - "namespace": "app.v1", + "namespace": "app.", "operation": "users.fetch.by_id", "args": [1], }, @@ -729,9 +729,12 @@ def tagged_args(raw: list) -> list: { "name": "reject_double_dot_operation", "namespace": "users", - "operation": "x..y", + "operation": "x..", "args": [], - "error": "operation must not contain '..': the pattern admits it, but the server rejects '..' anywhere in a key", + "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)" + ), }, ] From 8c547ed113ecdf6c2dea66bd3dc39bad760e02cc Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 30 Sep 2026 14:14:02 +1000 Subject: [PATCH 3/3] docs(matrix): link the SDK PRs that vendor interop fixture 1.2.0 (LAB-5906) The Test vectors in CI cells name fixture 1.2.0 and cachekit-py#391, cachekit-rs#98 and cachekit-ts#164, all unreleased. The Python cell says fixture 1.1.0 ships in PyPI 0.20.0, which is published. --- changelog.d/20260930_lab-5906.md | 2 ++ sdk-feature-matrix.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/changelog.d/20260930_lab-5906.md b/changelog.d/20260930_lab-5906.md index f7706b0..57ebbe6 100644 --- a/changelog.d/20260930_lab-5906.md +++ b/changelog.d/20260930_lab-5906.md @@ -18,6 +18,8 @@ vector with a `.` in a segment. Counts: 35 key, 13 error. - The status banner and SaaS Considerations no longer name `..` as an exception to the grammar being a subset of what the server accepts. +- SDK feature matrix: the "Test vectors in CI" cells link the SDK PRs that vendor fixture + 1.2.0, none released yet. The Python cell now says fixture 1.1.0 ships in PyPI 0.20.0. - `tools/interop-reference.py` rejects a `..` segment, and its self-check asserts that both new error vectors match `segment_pattern`, so they exercise the new rule rather than the pattern. `tools/interop-crosscheck.mjs` hard-codes the rule rather than diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md index b35ccaa..ac102c4 100644 --- a/sdk-feature-matrix.md +++ b/sdk-feature-matrix.md @@ -299,7 +299,7 @@ its spec: | AAD v0x03 | ✅ Compliant (5 components — every auto serializer appends `original_type`; interop mode is the sole 4-component path) | ✅ Compliant (4 components) | ✅ Compliant (4 components) | ❌ Not implemented | | SaaS API | ✅ Compliant (CachekitIO backend) | ✅ Compliant (CachekitIO backend) | ✅ Compliant | ❌ Not implemented | | SaaS API — cache-key path encoding ([spec](spec/saas-api.md#cache-key-path-encoding)) | ✅ Compliant on `main`, unreleased ([cachekit-py#364](https://github.com/cachekit-io/cachekit-py/pull/364)); rules 1/3/4 since 0.18.0 ([cachekit-py#279](https://github.com/cachekit-io/cachekit-py/pull/279)) | ✅ Compliant on `main`, unreleased ([cachekit-rs#76](https://github.com/cachekit-io/cachekit-rs/pull/76)) | ✅ Compliant on `main`, unreleased ([cachekit-ts#118](https://github.com/cachekit-io/cachekit-ts/pull/118)) | ❌ Not implemented | -| Test vectors in CI¹⁶ | ✅ interop/v1 (full set, incl. AAD + encryption through the real stack) — fixture 1.1.0 (`ns`/`nsapi` namespace reservation) in [cachekit-py#350](https://github.com/cachekit-io/cachekit-py/pull/350), unreleased; `decode-bounds.json` vendored + CI-executed since [cachekit-py#276](https://github.com/cachekit-io/cachekit-py/pull/276) (LAB-2503); `1.1.0` in [cachekit-py#363](https://github.com/cachekit-io/cachekit-py/pull/363), unreleased, asserting the structural guard's own error for every reject vector at every payload read path (the [Decode bounds](spec/interop-mode.md#decode-bounds) MUST) — ⚠️ except the envelope entry point (`ByteStorage::retrieve`), which has no guard to assert until cachekit-py moves to a core release carrying [cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80) (see the Wire format row); `path-encoding.json` vendored + CI-executed since [cachekit-py#364](https://github.com/cachekit-io/cachekit-py/pull/364), unreleased | ✅ interop/v1 (full set) since [#33](https://github.com/cachekit-io/cachekit-rs/pull/33) — fixture 1.1.0 in [cachekit-rs#89](https://github.com/cachekit-io/cachekit-rs/pull/89), unreleased; `decode-bounds.json` vendored + CI-executed since [cachekit-rs#73](https://github.com/cachekit-io/cachekit-rs/pull/73) (LAB-2503; default CI green on `main`); `1.1.0` in [cachekit-rs#93](https://github.com/cachekit-io/cachekit-rs/pull/93), unreleased, asserting the structural guard's own `decode bound:` error for every reject vector through both decoders and `get` / `interop_get` / `interop_get_swr` — the guard enforces depth itself since that PR (no envelope entry point¹⁵) | ✅ interop/v1 (full set, incl. its key vectors) + inline Python-generated AAD-construction and encryption (decrypt-Python-ciphertext) vectors — fixture 1.1.0 in [cachekit-ts#143](https://github.com/cachekit-io/cachekit-ts/pull/143), unreleased; decode bounds enforced ([#112](https://github.com/cachekit-io/cachekit-ts/pull/112)); `decode-bounds.json` vendored + CI-executed since [cachekit-ts#121](https://github.com/cachekit-io/cachekit-ts/pull/121) (LAB-2737), `1.1.0` in [cachekit-ts#152](https://github.com/cachekit-io/cachekit-ts/pull/152), unreleased, asserting the guard error each vector trips (pre-scan, or the event size cap ahead of it) — ⚠️ except the envelope entry point (`unpack` in `cachekit-core-ts` and `cachekit-core-wasm`), which has no guard to assert until both bindings move to a core release carrying [cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80) and the vectors run through `unpack` (see the Wire format row); `path-encoding.json` vendored + CI-executed since [cachekit-ts#118](https://github.com/cachekit-io/cachekit-ts/pull/118), unreleased | ⚠️ Pending | +| Test vectors in CI¹⁶ | ✅ interop/v1 (full set, incl. AAD + encryption through the real stack) — fixture 1.1.0 (`ns`/`nsapi` namespace reservation) since PyPI 0.20.0 ([cachekit-py#350](https://github.com/cachekit-io/cachekit-py/pull/350)); fixture 1.2.0 (no `..` in a segment) in [cachekit-py#391](https://github.com/cachekit-io/cachekit-py/pull/391), unreleased; `decode-bounds.json` vendored + CI-executed since [cachekit-py#276](https://github.com/cachekit-io/cachekit-py/pull/276) (LAB-2503); `1.1.0` in [cachekit-py#363](https://github.com/cachekit-io/cachekit-py/pull/363), unreleased, asserting the structural guard's own error for every reject vector at every payload read path (the [Decode bounds](spec/interop-mode.md#decode-bounds) MUST) — ⚠️ except the envelope entry point (`ByteStorage::retrieve`), which has no guard to assert until cachekit-py moves to a core release carrying [cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80) (see the Wire format row); `path-encoding.json` vendored + CI-executed since [cachekit-py#364](https://github.com/cachekit-io/cachekit-py/pull/364), unreleased | ✅ interop/v1 (full set) since [#33](https://github.com/cachekit-io/cachekit-rs/pull/33) — fixture 1.1.0 in [cachekit-rs#89](https://github.com/cachekit-io/cachekit-rs/pull/89), unreleased; fixture 1.2.0 in [cachekit-rs#98](https://github.com/cachekit-io/cachekit-rs/pull/98), unreleased; `decode-bounds.json` vendored + CI-executed since [cachekit-rs#73](https://github.com/cachekit-io/cachekit-rs/pull/73) (LAB-2503; default CI green on `main`); `1.1.0` in [cachekit-rs#93](https://github.com/cachekit-io/cachekit-rs/pull/93), unreleased, asserting the structural guard's own `decode bound:` error for every reject vector through both decoders and `get` / `interop_get` / `interop_get_swr` — the guard enforces depth itself since that PR (no envelope entry point¹⁵) | ✅ interop/v1 (full set, incl. its key vectors) + inline Python-generated AAD-construction and encryption (decrypt-Python-ciphertext) vectors — fixture 1.1.0 in [cachekit-ts#143](https://github.com/cachekit-io/cachekit-ts/pull/143), unreleased; fixture 1.2.0 in [cachekit-ts#164](https://github.com/cachekit-io/cachekit-ts/pull/164), unreleased; decode bounds enforced ([#112](https://github.com/cachekit-io/cachekit-ts/pull/112)); `decode-bounds.json` vendored + CI-executed since [cachekit-ts#121](https://github.com/cachekit-io/cachekit-ts/pull/121) (LAB-2737), `1.1.0` in [cachekit-ts#152](https://github.com/cachekit-io/cachekit-ts/pull/152), unreleased, asserting the guard error each vector trips (pre-scan, or the event size cap ahead of it) — ⚠️ except the envelope entry point (`unpack` in `cachekit-core-ts` and `cachekit-core-wasm`), which has no guard to assert until both bindings move to a core release carrying [cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80) and the vectors run through `unpack` (see the Wire format row); `path-encoding.json` vendored + CI-executed since [cachekit-ts#118](https://github.com/cachekit-io/cachekit-ts/pull/118), unreleased | ⚠️ Pending | | Interop mode ([spec](spec/interop-mode.md), opt-in) | ✅ Released — PyPI 0.14.0+¹⁷ ([#220](https://github.com/cachekit-io/cachekit-py/pull/220)) | ✅ Released — crates.io 0.4.0+ ([#33](https://github.com/cachekit-io/cachekit-rs/pull/33)) | ✅ Released — npm 0.1.3+ ([#71](https://github.com/cachekit-io/cachekit-ts/pull/71)) | ❌ Not implemented | > [!NOTE]