From 23a4a61d3a795287b58bd526dd8ea10c448765e5 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 30 Sep 2026 14:10:00 +1000 Subject: [PATCH 1/2] 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 other backends. validateInteropSegment now throws ConfigurationError for a namespace or operation containing `..`, after the grammar check, on every backend. A lone `.` stays valid. The WrapOptions.interop JSDoc 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 throws ConfigurationError at wrap time, on every backend. Rename the segment; its keys become a full cache miss. --- .secrets.baseline | 12 +++---- packages/cachekit/src/cache.interop.test.ts | 32 +++++++++++++++++++ .../cachekit/src/serialization/interop.ts | 14 +++++++- packages/cachekit/src/types/cache.ts | 4 ++- .../test/protocol/fixtures/interop-mode.json | 30 +++++++++++++++-- .../protocol/interop-mode.protocol.test.ts | 10 +++--- 6 files changed, 87 insertions(+), 15 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index e5d3d29..34d4626 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -520,35 +520,35 @@ "filename": "packages/cachekit/test/protocol/fixtures/interop-mode.json", "hashed_secret": "be5aef15f4e2838807bbd3eae0273d810d3b06d9", "is_verified": false, - "line_number": 618 + "line_number": 630 }, { "type": "Hex High Entropy String", "filename": "packages/cachekit/test/protocol/fixtures/interop-mode.json", "hashed_secret": "e10287fe94b240c3f1b80108a6f38ba327656d1a", "is_verified": false, - "line_number": 649 + "line_number": 661 }, { "type": "Hex High Entropy String", "filename": "packages/cachekit/test/protocol/fixtures/interop-mode.json", "hashed_secret": "22451150856583e1d666836db8b8f8069e5180bb", "is_verified": false, - "line_number": 751 + "line_number": 777 }, { "type": "Hex High Entropy String", "filename": "packages/cachekit/test/protocol/fixtures/interop-mode.json", "hashed_secret": "43f8a5e00c7a0964cdf90022b9eb2cab0b0f96ca", "is_verified": false, - "line_number": 760 + "line_number": 786 }, { "type": "Hex High Entropy String", "filename": "packages/cachekit/test/protocol/fixtures/interop-mode.json", "hashed_secret": "f0890edf4761a1bc77aa54ed51547f2f8bc2bcb3", "is_verified": false, - "line_number": 767 + "line_number": 793 } ], "packages/cachekit/test/protocol/wire-format.protocol.test.ts": [ @@ -789,5 +789,5 @@ } ] }, - "generated_at": "2026-09-29T12:55:18Z" + "generated_at": "2026-09-30T04:09:50Z" } diff --git a/packages/cachekit/src/cache.interop.test.ts b/packages/cachekit/src/cache.interop.test.ts index 72e467d..0cc9cd4 100644 --- a/packages/cachekit/src/cache.interop.test.ts +++ b/packages/cachekit/src/cache.interop.test.ts @@ -336,6 +336,38 @@ describe('cache.wrap interop mode', () => { }) ).toThrow(/reserved/); } + // The server rejects '..' anywhere in a key, so neither segment may hold it. + for (const [namespace, interop] of [ + ['a..b', 'get_user'], + ['users', 'x..y'], + ] as const) { + expect(() => + cache!.wrap(async () => 1, { + namespace, + interop, + interopArity: 0, + ttl: 60, + }) + ).toThrow(/must not contain '\.\.'/); + } + }); + + it('accepts lone dots in segments and writes the pinned vector key', async () => { + const backend = new InMemoryBackend(); + cache = createCache({ backend, l1: { enabled: false } }); + + const fn = cache.wrap(async (x: number) => x, { + namespace: 'app.', + interop: 'users.fetch.by_id', + interopArity: 1, + ttl: 60, + }); + await fn(1); + + // lone_dots_stay_valid in test-vectors/interop-mode.json. + expect([...backend.store.keys()]).toEqual([ + 'app.:users.fetch.by_id:405f09a3617bcc1425ea95b9840d9c2713e3ecebd5a3227abc599317b732e21a', + ]); }); it('encrypts interop entries with compressed=False AAD (cross-SDK decryptable)', async () => { diff --git a/packages/cachekit/src/serialization/interop.ts b/packages/cachekit/src/serialization/interop.ts index 6d75a53..8ba4334 100644 --- a/packages/cachekit/src/serialization/interop.ts +++ b/packages/cachekit/src/serialization/interop.ts @@ -96,12 +96,18 @@ export class InteropFloat { * vectors). Exact-match and namespace-only: `nsx` is a valid namespace, and * `ns` / `nsapi` are valid operations. * + * Neither segment may contain `..`: the pattern admits it, but the server + * rejects `..` anywhere in a key (the Traversal row of the same spec section), + * so the key would fail on every request (the `reject_double_dot_*` vectors). + * The `:` delimiters separate the segments and the hash is hex, so any `..` in + * a key lies inside one segment. A lone `.` stays valid. + * * A non-string is rejected first: RegExp.test string-coerces its argument but * Set.has does not, so an untyped `['ns']` would otherwise pass the grammar * and skip the reservation. * * @throws {ConfigurationError} if the segment is not a string, does not match - * the grammar, or is a reserved namespace + * the grammar, contains `..`, or is a reserved namespace */ export function validateInteropSegment(kind: 'namespace' | 'operation', value: string): void { if (typeof value !== 'string') { @@ -113,6 +119,12 @@ export function validateInteropSegment(kind: 'namespace' | 'operation', value: s `^[a-z0-9][a-z0-9._-]{0,63}$ (lowercase ASCII letters, digits, '.', '_', '-'; 1-64 chars)` ); } + if (value.includes('..')) { + throw new ConfigurationError( + `Invalid interop ${kind} ${JSON.stringify(value)}: must not contain '..' — ` + + `the CachekitIO server rejects '..' anywhere in a key` + ); + } if (kind === 'namespace' && RESERVED_INTEROP_NAMESPACES.has(value)) { throw new ConfigurationError( `Invalid interop namespace ${JSON.stringify(value)}: 'ns' and 'nsapi' are reserved — ` + diff --git a/packages/cachekit/src/types/cache.ts b/packages/cachekit/src/types/cache.ts index c33cd20..bcf552f 100644 --- a/packages/cachekit/src/types/cache.ts +++ b/packages/cachekit/src/types/cache.ts @@ -85,7 +85,9 @@ export type WrapOptions = WrapOptionsBase & * operation name must match `^[a-z0-9][a-z0-9._-]{0,63}$` (validated * at wrap time). `ns` and `nsapi` are reserved as namespaces (the * CachekitIO server parses a key starting `ns:` / `nsapi:` as - * namespace-prefixed); operation names are unaffected. + * namespace-prefixed); operation names are unaffected. Neither may + * contain `..` (the server rejects `..` anywhere in a key); a lone + * `.` is fine. * * Fails closed — at wrap time and on every call — if the backend * applies a key prefix (e.g. Redis `keyPrefix`): a prefixed interop diff --git a/packages/cachekit/test/protocol/fixtures/interop-mode.json b/packages/cachekit/test/protocol/fixtures/interop-mode.json index 9da1654..5748c4f 100644 --- a/packages/cachekit/test/protocol/fixtures/interop-mode.json +++ b/packages/cachekit/test/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/packages/cachekit/test/protocol/interop-mode.protocol.test.ts b/packages/cachekit/test/protocol/interop-mode.protocol.test.ts index 305a294..3979f71 100644 --- a/packages/cachekit/test/protocol/interop-mode.protocol.test.ts +++ b/packages/cachekit/test/protocol/interop-mode.protocol.test.ts @@ -10,8 +10,8 @@ * tools/interop-crosscheck.mjs; this suite is the cachekit-ts SDK's own * mandatory verification (spec "SDK Implementation Requirements" #7). * - * Provenance: cachekit-io/protocol test-vectors/interop-mode.json 1.1.0 at - * 965aeb01a4e8b9e7a0c9ca576b4c2cb60b63b918, copied byte-for-byte. Re-vendoring + * Provenance: cachekit-io/protocol test-vectors/interop-mode.json 1.2.0 from + * https://github.com/cachekit-io/protocol/pull/94, copied byte-for-byte. Re-vendoring * means refreshing FIXTURE_SHA256 and the counts in the first test. */ @@ -82,7 +82,7 @@ interface VectorFile { } /** sha256 of test-vectors/interop-mode.json at the provenance above. */ -const FIXTURE_SHA256 = '9b1855851d888c479e37a8fff9e9bbe5738737a9a408e749d9126c7678b9e7bc'; // pragma: allowlist secret +const FIXTURE_SHA256 = '702613766d1b92bc3a337627a96b9aedc89abfeb4d9208c2bb00c9539a0a1f40'; // pragma: allowlist secret const raw = readFileSync( join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'interop-mode.json') @@ -212,9 +212,9 @@ describe('interop/v1 vector fixture', () => { createHash('sha256').update(raw).digest('hex'), 'fixture differs from the pinned protocol revision; if intentional, refresh FIXTURE_SHA256 AND the counts' ).toBe(FIXTURE_SHA256); - expect(vectors.key_vectors).toHaveLength(34); + expect(vectors.key_vectors).toHaveLength(35); expect(vectors.value_vectors).toHaveLength(4); - expect(vectors.error_vectors).toHaveLength(11); + expect(vectors.error_vectors).toHaveLength(13); }); }); From f15d3b64f2cb979412c299c2c058500eb45d2bb5 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 30 Sep 2026 14:33:12 +1000 Subject: [PATCH 2/2] docs(interop): generateInteropKey @throws names the '..' ban (LAB-5906) --- packages/cachekit/src/serialization/interop.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cachekit/src/serialization/interop.ts b/packages/cachekit/src/serialization/interop.ts index 8ba4334..68042b8 100644 --- a/packages/cachekit/src/serialization/interop.ts +++ b/packages/cachekit/src/serialization/interop.ts @@ -573,7 +573,7 @@ export function interopArgsHash(args: readonly unknown[]): string { * auto-mode truncation rule never applies. * * @throws {ConfigurationError} if namespace or operation violate the segment - * grammar, or namespace is reserved (`ns`, `nsapi`) + * grammar or contain `..`, or namespace is reserved (`ns`, `nsapi`) * @throws {SerializationError} if an argument is outside the interop data model */ export function generateInteropKey(