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.

32 changes: 32 additions & 0 deletions packages/cachekit/src/cache.interop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
16 changes: 14 additions & 2 deletions packages/cachekit/src/serialization/interop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand All @@ -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 — ` +
Expand Down Expand Up @@ -561,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(
Expand Down
4 changes: 3 additions & 1 deletion packages/cachekit/src/types/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 28 additions & 2 deletions packages/cachekit/test/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
10 changes: 5 additions & 5 deletions packages/cachekit/test/protocol/interop-mode.protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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);
});
});

Expand Down
Loading