From 38f33a724203bef7656f3e390f30db0212c94b6c Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 30 Sep 2026 14:11:15 +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 other backends. validate_segment now returns CachekitError::InvalidKey for a namespace or operation containing `..`, after the grammar check, and the macro's compile-time mirror (parse_segment) makes it a compile error for either `namespace` or `interop`. A lone `.` stays valid. The README 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 fails: interop_key returns InvalidKey, and #[cachekit] does not compile. Rename the segment; its keys become a full cache miss. --- README.md | 2 +- crates/cachekit-macros/src/lib.rs | 45 +++++++++++++--- crates/cachekit/src/interop.rs | 53 ++++++++++++++++--- crates/cachekit/tests/interop_vector_tests.rs | 10 ++-- .../cachekit/tests/vectors/interop-mode.json | 30 ++++++++++- 5 files changed, 118 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 858a94e..c7dd16a 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ cache.set_with_ttl(&key, &user, ttl).await?; // plain MessagePack — a let user: Option = cache.interop_get(&key).await?; // strict read: exactly one document ``` -`ns` and `nsapi` are reserved as namespaces — the CachekitIO server parses a key starting `ns:` or `nsapi:` as namespace-prefixed — so `interop_key` rejects them with `InvalidKey` and `#[cachekit(namespace = ...)]` with a compile error; operations are unaffected. +`ns` and `nsapi` are reserved as namespaces — the CachekitIO server parses a key starting `ns:` or `nsapi:` as namespace-prefixed — so `interop_key` rejects them with `InvalidKey` and `#[cachekit(namespace = ...)]` with a compile error; operations are unaffected. Neither segment may contain `..` (`a..b` is rejected the same two ways; a lone `.`, as in `app.v1`, is fine), because the server rejects `..` anywhere in a key. Argument hashing is byte-identical across SDKs (canonical MessagePack + Blake2b-256), verified against the shared [protocol](https://github.com/cachekit-io/protocol) test vectors ([`interop-mode.json`](crates/cachekit/tests/vectors/interop-mode.json), vendored) in this repo's test suite. `interop_get` (also on `SecureCache`) rejects trailing bytes and Python-internal CK frames instead of silently misreading them. Every decode of backend-supplied bytes (`get` and `interop_get` alike) first passes a header-only structural walk that rejects, before anything is decoded, a document nested deeper than `serializer::MAX_DECODE_DEPTH` (100 levels, matching the TypeScript SDK; every collection header counts, empty ones included) or declaring more elements or bytes than the input can back, verified against the protocol's shared [`decode-bounds.json`](crates/cachekit/tests/vectors/decode-bounds.json) vectors (vendored) — a forged nested-header entry is a bounded `Serialization` error, not a memory blow-up or a stack overflow. Encryption works unchanged — interop keys are identical across SDKs, so the AAD verifies cross-SDK. diff --git a/crates/cachekit-macros/src/lib.rs b/crates/cachekit-macros/src/lib.rs index a5e7ce4..7d47755 100644 --- a/crates/cachekit-macros/src/lib.rs +++ b/crates/cachekit-macros/src/lib.rs @@ -22,8 +22,8 @@ struct MacroArgs { /// (`^[a-z0-9][a-z0-9._-]{0,63}$` — see `cachekit::interop`'s /// `validate_segment`, the canonical implementation). Duplicated because a /// proc-macro crate cannot depend on the runtime crate; `interop_key` -/// re-validates at runtime, so drift fails loudly, never silently. The -/// reserved-namespace check is mirrored in `parse_segment`. +/// re-validates at runtime, so drift fails loudly, never silently. The `..` +/// ban and the reserved-namespace check are mirrored in `parse_segment`. fn segment_is_valid(segment: &str) -> bool { let bytes = segment.as_bytes(); matches!(bytes.first(), Some(b) if b.is_ascii_lowercase() || b.is_ascii_digit()) @@ -36,9 +36,11 @@ fn segment_is_valid(segment: &str) -> bool { /// Extract and validate an interop segment from a string literal, spanning /// the error to the literal. /// -/// Mirrors `validate_segment`'s namespace reservation: a `namespace` of exactly -/// `ns` or `nsapi` is rejected (the CachekitIO server parses a key starting -/// `ns:` / `nsapi:` as namespace-prefixed); both stay valid as `interop`. +/// Mirrors `validate_segment`'s `..` ban (either segment; the CachekitIO +/// server rejects `..` anywhere in a key) and its namespace reservation: a +/// `namespace` of exactly `ns` or `nsapi` is rejected (the server parses a key +/// starting `ns:` / `nsapi:` as namespace-prefixed); both stay valid as +/// `interop`. fn parse_segment(kind: &str, lit: &LitStr) -> syn::Result { let value = lit.value(); if !segment_is_valid(&value) { @@ -51,6 +53,15 @@ fn parse_segment(kind: &str, lit: &LitStr) -> syn::Result { ), )); } + if value.contains("..") { + return Err(syn::Error::new( + lit.span(), + format!( + "`{kind}` {value:?} must not contain \"..\": the CachekitIO server rejects \ + \"..\" anywhere in a key" + ), + )); + } if kind == "namespace" && matches!(value.as_str(), "ns" | "nsapi") { return Err(syn::Error::new( lit.span(), @@ -197,7 +208,8 @@ fn extract_ok_type(ret: &ReturnType) -> syn::Result { /// - `namespace = ""` (required): interop/v1 namespace segment, /// same grammar. `ns` and `nsapi` are reserved as namespaces (a compile /// error — the CachekitIO server parses those key prefixes); operations are -/// unaffected. +/// unaffected. Neither segment may contain `..` (a compile error — the +/// server rejects `..` anywhere in a key); a lone `.` is fine. /// - `secure` (optional flag): Use encrypted cache via `cache.secure_cache()`. /// /// # Requirements @@ -721,4 +733,25 @@ mod tests { assert!(attr("get_user", "nsx").is_ok()); assert!(attr("get_user", "nsapi2").is_ok()); } + + /// Mirror of interop.rs's `..` ban: a compile error in either segment, + /// while lone dots, including a trailing one, stay valid. + #[test] + fn double_dot_segment_is_a_compile_error() { + let attr = |interop: &str, namespace: &str| { + syn::parse_str::(&format!( + "client = cache, ttl = 60, interop = {interop:?}, namespace = {namespace:?}" + )) + }; + for (interop, namespace) in [("get_user", "a..b"), ("x..y", "users"), ("x..", "users")] { + let Err(err) = attr(interop, namespace) else { + panic!("{interop:?}/{namespace:?} must be rejected"); + }; + assert!( + err.to_string().contains("must not contain"), + "error must name the '..' ban, got: {err}" + ); + } + assert!(attr("users.fetch.by_id", "app.").is_ok()); + } } diff --git a/crates/cachekit/src/interop.rs b/crates/cachekit/src/interop.rs index 7934295..77e583f 100644 --- a/crates/cachekit/src/interop.rs +++ b/crates/cachekit/src/interop.rs @@ -11,9 +11,10 @@ //! ``` //! //! `namespace` and `operation` are user-supplied, validated against -//! `^[a-z0-9][a-z0-9._-]{0,63}$` (full-string). `namespace` must also not be -//! exactly `ns` or `nsapi`: the CachekitIO server parses a key starting `ns:` -//! or `nsapi:` as namespace-prefixed. `args_hash` is the Blake2b-256 +//! `^[a-z0-9][a-z0-9._-]{0,63}$` (full-string), and neither may contain `..` +//! (the CachekitIO server rejects `..` anywhere in a key). `namespace` must +//! also not be exactly `ns` or `nsapi`: the CachekitIO server parses a key +//! starting `ns:` or `nsapi:` as namespace-prefixed. `args_hash` is the Blake2b-256 //! digest (lowercase hex) of the canonical MessagePack encoding of the flat //! argument array. Unlike auto mode, there is no `func:` segment — the //! operation identity is explicit, so every SDK computes the same key for the @@ -248,7 +249,8 @@ impl TryFrom for InteropValue { // ── Segment validation ─────────────────────────────────────────────────────── /// Validate a key segment against `^[a-z0-9][a-z0-9._-]{0,63}$` as a -/// full-string match, and reject the reserved namespaces `ns` and `nsapi`. +/// full-string match, reject `..` inside it, and reject the reserved +/// namespaces `ns` and `nsapi`. /// /// Byte-wise iteration over the whole string makes this a full match by /// construction — a trailing `\n` (which Python's `re.match` + `$` would @@ -259,8 +261,13 @@ impl TryFrom for InteropValue { /// `nsapi:` as namespace-prefixed, so such an interop key would be rejected or /// misrouted there. `ns` and `nsapi` stay valid operations. /// -/// NOTE: `cachekit-macros` carries a compile-time mirror of this grammar and -/// the reservation (`segment_is_valid` / `parse_segment`) — proc-macro crates +/// The grammar admits `..`, but the server rejects `..` anywhere in a key, so +/// such a key would fail on every CachekitIO request. The `:` delimiters +/// separate the segments and the hash is hex, so any `..` in a key lies +/// inside one segment. A lone `.` stays valid. +/// +/// NOTE: `cachekit-macros` carries a compile-time mirror of this grammar, the +/// `..` ban and the reservation (`segment_is_valid` / `parse_segment`) — proc-macro crates /// cannot depend on this crate. If you change either here, change it there in /// the same diff. fn validate_segment(kind: &str, segment: &str) -> Result<(), CachekitError> { @@ -276,6 +283,12 @@ fn validate_segment(kind: &str, segment: &str) -> Result<(), CachekitError> { (lowercase ASCII letters, digits, '.', '_', '-'; 1-64 chars)" ))); } + if segment.contains("..") { + return Err(CachekitError::InvalidKey(format!( + "interop {kind} {segment:?} must not contain \"..\": the CachekitIO server \ + rejects \"..\" anywhere in a key" + ))); + } if kind == "namespace" && matches!(segment, "ns" | "nsapi") { return Err(CachekitError::InvalidKey(format!( "interop namespace {segment:?} is reserved: the CachekitIO server parses a key \ @@ -296,8 +309,8 @@ fn validate_segment(kind: &str, segment: &str) -> Result<(), CachekitError> { /// # Errors /// /// - [`CachekitError::InvalidKey`] if `namespace` or `operation` fails the -/// segment grammar (rejected, never normalized), or `namespace` is the -/// reserved `ns` or `nsapi`. +/// segment grammar (rejected, never normalized), contains `..`, or +/// `namespace` is the reserved `ns` or `nsapi`. /// - [`CachekitError::Serialization`] if any argument is outside the interop /// data model's ranges (non-finite float, integer outside `[-2^63, 2^64-1]`). pub fn interop_key( @@ -709,6 +722,30 @@ mod tests { assert!(interop_key("users.v2_x-y", "op", &[]).is_ok()); } + #[test] + fn segment_rejects_double_dot() { + for (namespace, operation) in [ + ("a..b", "get_user"), + ("users", "x..y"), + ("users", "x.."), + ("ab..", "get_user"), + ("a...b", "get_user"), + ] { + let err = interop_key(namespace, operation, &[]).unwrap_err(); + assert!( + matches!(&err, CachekitError::InvalidKey(msg) if msg.contains("must not contain")), + "{namespace:?}/{operation:?} must be rejected for '..', got: {err}" + ); + } + // Lone dots, including a trailing one, stay valid. + for (namespace, operation) in [("app.", "users.fetch.by_id"), ("app.v1", "get.")] { + assert!( + interop_key(namespace, operation, &[]).is_ok(), + "{namespace:?}/{operation:?} should be accepted" + ); + } + } + #[test] fn namespace_rejects_reserved_ns_and_nsapi() { for reserved in ["ns", "nsapi"] { diff --git a/crates/cachekit/tests/interop_vector_tests.rs b/crates/cachekit/tests/interop_vector_tests.rs index 1ab87fd..578b444 100644 --- a/crates/cachekit/tests/interop_vector_tests.rs +++ b/crates/cachekit/tests/interop_vector_tests.rs @@ -2,9 +2,9 @@ //! byte-verification of the shared cross-SDK vectors. //! //! Vectors: `tests/vectors/interop-mode.json`, vendored verbatim from -//! cachekit-io/protocol test-vectors/interop-mode.json 1.1.0 -//! () -//! (sha256 `9b1855851d888c479e37a8fff9e9bbe5738737a9a408e749d9126c7678b9e7bc`). +//! cachekit-io/protocol test-vectors/interop-mode.json 1.2.0 +//! () +//! (sha256 `702613766d1b92bc3a337627a96b9aedc89abfeb4d9208c2bb00c9539a0a1f40`). //! Do not edit the JSON here; regenerate upstream and re-vendor. //! //! Vector inputs use the tagged-JSON convention documented in the file header @@ -215,7 +215,7 @@ fn iso8601_to_unix_micros(s: &str) -> Result { fn key_vectors_all_pass() { let doc = vectors(); let key_vectors = doc["key_vectors"].as_array().expect("key_vectors array"); - assert_eq!(key_vectors.len(), 34, "expected 34 key vectors"); + assert_eq!(key_vectors.len(), 35, "expected 35 key vectors"); for vector in key_vectors { let name = vector["name"].as_str().expect("vector name"); @@ -290,7 +290,7 @@ fn value_vectors_all_pass() { fn error_vectors_all_reject() { let doc = vectors(); let error_vectors = doc["error_vectors"].as_array().expect("error_vectors"); - assert_eq!(error_vectors.len(), 11, "expected 11 error vectors"); + assert_eq!(error_vectors.len(), 13, "expected 13 error vectors"); for vector in error_vectors { let name = vector["name"].as_str().expect("vector name"); diff --git a/crates/cachekit/tests/vectors/interop-mode.json b/crates/cachekit/tests/vectors/interop-mode.json index 9da1654..5748c4f 100644 --- a/crates/cachekit/tests/vectors/interop-mode.json +++ b/crates/cachekit/tests/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 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": [