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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ cache.set_with_ttl(&key, &user, ttl).await?; // plain MessagePack — a
let user: Option<User> = 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.

Expand Down
45 changes: 39 additions & 6 deletions crates/cachekit-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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<String> {
let value = lit.value();
if !segment_is_valid(&value) {
Expand All @@ -51,6 +53,15 @@ fn parse_segment(kind: &str, lit: &LitStr) -> syn::Result<String> {
),
));
}
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(),
Expand Down Expand Up @@ -197,7 +208,8 @@ fn extract_ok_type(ret: &ReturnType) -> syn::Result<Type> {
/// - `namespace = "<string>"` (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
Expand Down Expand Up @@ -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::<MacroArgs>(&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());
}
}
53 changes: 45 additions & 8 deletions crates/cachekit/src/interop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -248,7 +249,8 @@ impl TryFrom<std::time::SystemTime> 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
Expand All @@ -259,8 +261,13 @@ impl TryFrom<std::time::SystemTime> 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> {
Expand All @@ -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 \
Expand All @@ -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(
Expand Down Expand Up @@ -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"] {
Expand Down
10 changes: 5 additions & 5 deletions crates/cachekit/tests/interop_vector_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
//! (<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`).
//! Do not edit the JSON here; regenerate upstream and re-vendor.
//!
//! Vector inputs use the tagged-JSON convention documented in the file header
Expand Down Expand Up @@ -215,7 +215,7 @@ fn iso8601_to_unix_micros(s: &str) -> Result<i64, String> {
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");
Expand Down Expand Up @@ -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");
Expand Down
30 changes: 28 additions & 2 deletions crates/cachekit/tests/vectors/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
Loading