diff --git a/packages/cultnet-rs/src/contracts.rs b/packages/cultnet-rs/src/contracts.rs index 93ece35e..d5436deb 100644 --- a/packages/cultnet-rs/src/contracts.rs +++ b/packages/cultnet-rs/src/contracts.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use anyhow::Result; use anyhow::anyhow; use base64::Engine; @@ -291,8 +293,18 @@ pub enum CultNetMessage { Verify { nonce: String, session: String }, #[serde(rename = "cultnet.login_success.v0", rename_all = "camelCase")] LoginSuccess { nonce: String, session: String }, + /// R-N (docs/cultnet-selection-cut.md, fix batch 3): `code` and `details` are additive over + /// the pre-cut `error` string, so a v0 peer that has never heard of them keeps working + /// unchanged. Every selection and cursor refusal, and `reference_outside_target`, is carried + /// this way in both servers and in Rust - see `SelectionRefusal::to_wire_message`. #[serde(rename = "cultnet.error.v0", rename_all = "camelCase")] - Error { error: String }, + Error { + error: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + details: Option>, + }, #[serde(rename = "cultnet.sample.change_name.v0", rename_all = "camelCase")] SampleChangeName { name: String }, #[serde(rename = "cultnet.sample.chat.v0", rename_all = "camelCase")] @@ -573,7 +585,7 @@ fn validate_message(message: &CultNetMessage) -> Result<()> { require_non_empty(nonce, "nonce")?; require_non_empty(session, "session")?; } - CultNetMessage::Error { error } => require_non_empty(error, "error")?, + CultNetMessage::Error { error, .. } => require_non_empty(error, "error")?, CultNetMessage::SampleChangeName { name } => require_non_empty(name, "name")?, CultNetMessage::SampleChat { text } => require_non_empty(text, "text")?, CultNetMessage::DocumentPut { @@ -1293,6 +1305,10 @@ fn parse_gamecult_networking_message(input: &rmpv::Value) -> Result Ok(CultNetMessage::Error { error: require_legacy_string(payload.first(), "ErrorMessage.Error")?, + // The legacy array wire has no slot for `code`/`details` (R-N); a v0 peer never sees + // them, on the wire or off it. + code: None, + details: None, }), 5 => Ok(CultNetMessage::SampleChangeName { name: require_legacy_string(payload.first(), "ChangeNameMessage.Name")?, @@ -1376,7 +1392,9 @@ fn encode_gamecult_networking_message(message: &CultNetMessage) -> Result { + CultNetMessage::Error { error, .. } => { + // R-N: `code`/`details` are additive on the named wire only; the legacy array wire + // has no slot for them and never carried them. vec![ rmpv::Value::from(4), rmpv::Value::Array(vec![error.as_str().into()]), diff --git a/packages/cultnet-rs/src/security.rs b/packages/cultnet-rs/src/security.rs index 30aa841b..76f23b23 100644 --- a/packages/cultnet-rs/src/security.rs +++ b/packages/cultnet-rs/src/security.rs @@ -259,7 +259,7 @@ fn hmac_sha256(key: &[u8], input: &[u8]) -> Result> { Ok(mac.finalize().into_bytes().to_vec()) } -fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { +pub(crate) fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { if left.len() != right.len() { return false; } diff --git a/packages/cultnet-rs/src/selection.rs b/packages/cultnet-rs/src/selection.rs index 6dc48da3..a17663a0 100644 --- a/packages/cultnet-rs/src/selection.rs +++ b/packages/cultnet-rs/src/selection.rs @@ -15,13 +15,21 @@ //! the comparator; it is a pure string algorithm with no `f64`/`f32` anywhere on the comparison path. use std::cmp::Ordering; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fmt; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use hmac::Hmac; +use hmac::Mac; +use rand::RngCore; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; +use sha2::Sha256; + +use crate::CultNetMessage; +use crate::security::constant_time_eq; + +type HmacSha256 = Hmac; // --------------------------------------------------------------------------------------------- // Canonical decimal numbers (Q-J) @@ -922,6 +930,70 @@ impl From for SelectionRefusal { } } +// --------------------------------------------------------------------------------------------- +// R-N (docs/cultnet-selection-cut.md, fix batch 3): refusals are wire messages +// --------------------------------------------------------------------------------------------- + +impl SelectionRefusal { + /// The wire `code` this refusal carries on `cultnet.error.v0` (R-N). Mirrors the C# + /// reference's mapping from `CultNetSelectionInvalidException` and the cursor/reference + /// refusals to the same string. + pub fn wire_code(&self) -> &'static str { + match self { + Self::Invalid(_) => "selection_invalid", + Self::CursorStale { .. } => "cursor_stale", + Self::CursorInvalid { .. } => "cursor_invalid", + Self::ReferenceOutsideTarget { .. } => "reference_outside_target", + } + } + + /// The wire `details` this refusal carries alongside `code` (R-N): a flat string map, so the + /// shape stays additive over the pre-cut `error` string on every transport this crate speaks. + pub fn wire_details(&self) -> BTreeMap { + let mut details = BTreeMap::new(); + match self { + Self::Invalid(inner) => { + details.insert("field".to_string(), inner.field.clone()); + if let Some(value) = &inner.value { + details.insert("value".to_string(), value.clone()); + } + } + Self::CursorStale { as_of, current } => { + details.insert("asOf".to_string(), as_of.to_string()); + details.insert("current".to_string(), current.to_string()); + } + Self::CursorInvalid { message } => { + details.insert("message".to_string(), message.clone()); + } + Self::ReferenceOutsideTarget { + from_schema_id, + from_key, + role, + to_schema_id, + to_key, + } => { + details.insert("fromSchemaId".to_string(), from_schema_id.clone()); + details.insert("fromKey".to_string(), from_key.clone()); + details.insert("role".to_string(), role.clone()); + details.insert("toSchemaId".to_string(), to_schema_id.clone()); + details.insert("toKey".to_string(), to_key.clone()); + } + } + details + } + + /// This refusal as the `cultnet.error.v0` message a peer actually receives (R-N): `error` + /// stays the human string every peer already reads, `code`/`details` are additive. + pub fn to_wire_message(&self) -> CultNetMessage { + let details = self.wire_details(); + CultNetMessage::Error { + error: self.to_string(), + code: Some(self.wire_code().to_string()), + details: if details.is_empty() { None } else { Some(details) }, + } + } +} + // --------------------------------------------------------------------------------------------- // Row / RowSet (D7): the consumer's declarations and values, reflected over nothing // --------------------------------------------------------------------------------------------- @@ -1385,6 +1457,7 @@ pub fn select( all_rows: &[R], selection: &Selection, as_of: u64, + cursor_key: &CursorKey, ) -> Result, SelectionRefusal> { validate(selection, row_set)?; @@ -1422,7 +1495,9 @@ pub fn select( && !cursor_text.is_empty() { let cursor = Cursor::parse(cursor_text)?; - if cursor.digest != Cursor::compute_digest(selection) { + // R-O: a forged cursor, or one minted under a different process's key, fails this check - + // digest equality alone used to be enough to mint one; now the key is required too. + if !cursor.verify_digest(selection, cursor_key) { return Err(SelectionRefusal::CursorInvalid { message: "The cursor's selection digest does not match this selection.".into(), }); @@ -1448,7 +1523,12 @@ pub fn select( .collect(); let has_next = start_index + page.len() < matched.len(); let next_cursor = if has_next && !page.is_empty() { - Some(Cursor::mint(as_of, page.last().expect("checked non-empty"), selection)) + Some(Cursor::mint( + as_of, + page.last().expect("checked non-empty"), + selection, + cursor_key, + )) } else { None }; @@ -1531,9 +1611,10 @@ pub fn select_page( all_rows: &[R], selection: &Selection, as_of: u64, + cursor_key: &CursorKey, document_record: impl Fn(&R) -> SelectionDocumentRecord, ) -> Result { - let evaluation = select(row_set, all_rows, selection, as_of)?; + let evaluation = select(row_set, all_rows, selection, as_of, cursor_key)?; let want_document = selection.projection == PROJECTION_DOCUMENT; let (headers, documents) = if want_document { @@ -1663,6 +1744,14 @@ fn matches_citation( ) -> Result { let mut found = false; for (role, target, payload) in citer.references() { + let Some(resolved) = by_key.get(target.record_key.as_str()) else { + continue; + }; + // R-T: every resolvable edge is checked against its declared target, not only the ones + // the citation's own role/key filters happen to ask about - a corrupt edge must not hide + // behind an unrelated filter (F15: the pre-fix evaluator skipped this check for every + // edge that did not point at the requested key). + ensure_within_declared_target(row_set, &role, citer, *resolved)?; if let Some(wanted_role) = &citation.role && *wanted_role != role { @@ -1671,10 +1760,6 @@ fn matches_citation( if target.record_key != citation.target.record_key { continue; } - let Some(resolved) = by_key.get(target.record_key.as_str()) else { - continue; - }; - ensure_within_declared_target(row_set, &role, citer, *resolved)?; // R-E: the target's schema id is matched through the one alias rule, not exact equality. let resolved_name = row_set .schema_name(resolved.schema_id()) @@ -1729,34 +1814,61 @@ fn build_incoming_index( // Cursor (section 2: "Snapshot") // --------------------------------------------------------------------------------------------- -/// The opaque cursor: `asOf`, the last `(ordinal, schemaId, recordKey)`, and a digest of the -/// selection with `cursor`/`limit` cleared. Minted only by the answering server. This runtime's own -/// mint/parse/digest algorithm is internal to it - a cursor is never handed across runtimes (each -/// server answers its own pages), so it need not (and does not) byte-match the C# reference's; the -/// *rule* it enforces (section 2 "Snapshot") does. +/// Random per-process key the answering server holds for minting and verifying its own cursors +/// (R-O). A cursor minted under one process's key is refused `cursor_invalid` by any other key, +/// including the same server's own key after a restart - cursors are short-lived and minted only +/// by the answering server (section 2, "Snapshot"), so not surviving a restart is the accepted +/// cost, not a bug. Never serialized and never persisted; hold one instance for the process's +/// lifetime and share it across every `select`/`select_page` call that process answers. +#[derive(Clone)] +pub struct CursorKey(Vec); + +impl CursorKey { + /// A fresh 32-byte key from the OS RNG. + pub fn random() -> Self { + let mut bytes = vec![0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + Self(bytes) + } +} + +impl fmt::Debug for CursorKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("CursorKey(..)") + } +} + +/// The opaque cursor: `asOf`, the last `(ordinal, schemaId, recordKey)`, and an HMAC digest of the +/// selection with `cursor`/`limit` cleared, keyed under the answering process's [`CursorKey`] +/// (R-O). Minted only by the answering server. This runtime's own mint/parse/digest algorithm is +/// internal to it - a cursor is never handed across runtimes (each server answers its own pages), +/// so it need not (and does not) byte-match the C# reference's; the *rule* it enforces (section 2 +/// "Snapshot") does. +/// +/// `parse` decodes the cursor's body and exposes `as_of` without the answering server's key - +/// Huginn depends on reading `as_of` this way, and a cursor's shape does not need the key to +/// decode, only its digest needs the key to *verify* (done by [`select`], which holds the key). #[derive(Clone, Debug, PartialEq, Eq)] pub struct Cursor { pub as_of: u64, pub ordinal: i64, pub schema_id: String, pub record_key: String, - pub digest: String, + digest: String, } -const CURSOR_FIELD_SEPARATOR: char = '\u{1}'; - impl Cursor { - pub fn mint(as_of: u64, last_row: &R, selection: &Selection) -> String { - let digest = Self::compute_digest(selection); - let raw = [ - as_of.to_string(), - last_row.ordinal().to_string(), - last_row.schema_id().to_string(), - last_row.record_key().to_string(), - digest, - ] - .join(&CURSOR_FIELD_SEPARATOR.to_string()); - URL_SAFE_NO_PAD.encode(raw.as_bytes()) + pub fn mint(as_of: u64, last_row: &R, selection: &Selection, key: &CursorKey) -> String { + let digest = Self::compute_digest(selection, key); + // R-O: the cursor body uses length-prefixed fields, not a delimiter - a record key + // carrying any character, including the pre-fix delimiter itself (F6), round-trips. + let mut body = String::new(); + write_length_prefixed(&mut body, &as_of.to_string()); + write_length_prefixed(&mut body, &last_row.ordinal().to_string()); + write_length_prefixed(&mut body, last_row.schema_id()); + write_length_prefixed(&mut body, last_row.record_key()); + write_length_prefixed(&mut body, &digest); + URL_SAFE_NO_PAD.encode(body.as_bytes()) } pub fn parse(cursor: &str) -> Result { @@ -1764,31 +1876,38 @@ impl Cursor { message: "The cursor does not decode.".into(), }; let bytes = URL_SAFE_NO_PAD.decode(cursor).map_err(|_| invalid())?; - let raw = String::from_utf8(bytes).map_err(|_| invalid())?; - let parts: Vec<&str> = raw.split(CURSOR_FIELD_SEPARATOR).collect(); - if parts.len() != 5 { - return Err(invalid()); - } - let as_of: u64 = parts[0].parse().map_err(|_| invalid())?; - let ordinal: i64 = parts[1].parse().map_err(|_| invalid())?; + let mut fields = read_length_prefixed_fields(&bytes, 5).ok_or_else(invalid)?; + let digest = fields.pop().unwrap(); + let record_key = fields.pop().unwrap(); + let schema_id = fields.pop().unwrap(); + let ordinal: i64 = fields.pop().unwrap().parse().map_err(|_| invalid())?; + let as_of: u64 = fields.pop().unwrap().parse().map_err(|_| invalid())?; Ok(Self { as_of, ordinal, - schema_id: parts[2].to_string(), - record_key: parts[3].to_string(), - digest: parts[4].to_string(), + schema_id, + record_key, + digest, }) } - /// A digest of the selection with `cursor` and `limit` cleared, so a cursor is bound to the - /// selection that minted it. + /// Verifies this cursor's digest against `selection` under `key` in constant time (R-O): a + /// cursor forged without the key, or minted by a different process's key, is refused. + fn verify_digest(&self, selection: &Selection, key: &CursorKey) -> bool { + let expected = Self::compute_digest(selection, key); + constant_time_eq(self.digest.as_bytes(), expected.as_bytes()) + } + + /// An HMAC-SHA256 digest, keyed under `key`, of the selection with `cursor` and `limit` + /// cleared, so a cursor is bound to the selection that minted it and to the process that + /// minted it. /// - /// R-H: every string and list is length-prefixed, so no separator character can ever make two - /// distinct selections collide (Soul found `["a|b"]` and `["a","b"]` digesting the same under - /// the previous separator-joined scheme). This need not (and does not) byte-match the C# - /// reference's own digest - each server answers its own pages (see this type's doc comment) - - /// only the collision-freedom is load-bearing. - pub fn compute_digest(selection: &Selection) -> String { + /// R-H: every string and list inside the selection is length-prefixed, so no separator + /// character can ever make two distinct selections collide (Soul found `["a|b"]` and + /// `["a","b"]` digesting the same under a separator-joined scheme). This need not (and does + /// not) byte-match the C# reference's own digest - each server answers its own pages (see this + /// type's doc comment) - only the collision-freedom and the keying are load-bearing. + pub fn compute_digest(selection: &Selection, key: &CursorKey) -> String { let mut sorted_schemas = selection.schemas.clone().unwrap_or_default(); sorted_schemas.sort(); let mut sorted_keys = selection.keys.clone().unwrap_or_default(); @@ -1831,10 +1950,11 @@ impl Cursor { write_length_prefixed(&mut canonical, &selection.projection); canonical.push(if selection.descending { 'D' } else { 'A' }); - let mut hasher = Sha256::new(); - hasher.update(canonical.as_bytes()); - hasher - .finalize() + let mut mac = ::new_from_slice(&key.0) + .expect("HMAC-SHA256 accepts a key of any length"); + mac.update(canonical.as_bytes()); + mac.finalize() + .into_bytes() .iter() .map(|byte| format!("{byte:02x}")) .collect() @@ -1858,3 +1978,25 @@ fn write_length_prefixed_list(buffer: &mut String, values: &[String]) { } buffer.push(']'); } + +/// Reads exactly `count` `:` fields written by [`write_length_prefixed`] back +/// out of `bytes`, in order. `None` on any malformed length, a truncated value, a non-UTF-8 value, +/// or leftover bytes after the last field - the cursor body has no field this reader may skip. +fn read_length_prefixed_fields(bytes: &[u8], count: usize) -> Option> { + let mut fields = Vec::with_capacity(count); + let mut pos = 0usize; + for _ in 0..count { + let colon = bytes[pos..].iter().position(|&b| b == b':')?; + let len: usize = std::str::from_utf8(&bytes[pos..pos + colon]).ok()?.parse().ok()?; + pos += colon + 1; + if pos + len > bytes.len() { + return None; + } + fields.push(std::str::from_utf8(&bytes[pos..pos + len]).ok()?.to_string()); + pos += len; + } + if pos != bytes.len() { + return None; + } + Some(fields) +} diff --git a/packages/cultnet-rs/tests/selection.rs b/packages/cultnet-rs/tests/selection.rs index fde97e0e..c5d6cc61 100644 --- a/packages/cultnet-rs/tests/selection.rs +++ b/packages/cultnet-rs/tests/selection.rs @@ -8,12 +8,19 @@ use std::fs; use std::path::Path; use cultnet_rs::{ - Citation, Cursor, CultNetMessage, CultNetWireContract, Edge, FieldPredicate, Incoming, + Citation, Cursor, CursorKey, CultNetMessage, CultNetWireContract, Edge, FieldPredicate, Incoming, RawDocumentHeader, RecordRef, Row, RowSet, Selection, SelectionDocumentRecord, SelectionOperator, SelectionPage, SelectionRefusal, canonical_number, decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, schema_alias, select, select_page, validate, }; +/// R-O: one process-lifetime key shared by every test in this binary, so a cursor minted in one +/// `select`/`select_page` call verifies in a later call of the same test. +fn test_cursor_key() -> &'static CursorKey { + static KEY: std::sync::OnceLock = std::sync::OnceLock::new(); + KEY.get_or_init(CursorKey::random) +} + // ------------------------------------------------------------------------------------------ // The fixture: two leaves under a conceptual abstract middle (mass declared on both, // independently - Rust has no inheritance to share it through), a citer with four reference @@ -342,7 +349,7 @@ fn conjoins_any_of_and_comparison_predicates() { ]), ..Selection::default() }; - let evaluation = select(&FixtureRowSet, &rows, &selection, 1).expect("selection is valid"); + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).expect("selection is valid"); let ids: Vec<&str> = evaluation.rows.iter().map(Row::record_key).collect(); assert_eq!(ids, vec!["a-eq"]); } @@ -351,7 +358,7 @@ fn conjoins_any_of_and_comparison_predicates() { #[test] fn orders_by_ordinal_then_identity_and_reverses() { let rows = base_rows(); - let ascending = select(&FixtureRowSet, &rows, &Selection::default(), 1).unwrap(); + let ascending = select(&FixtureRowSet, &rows, &Selection::default(), 1, test_cursor_key()).unwrap(); assert_eq!( ascending.rows.iter().map(Row::record_key).collect::>(), vec!["a-lo", "a-eq", "b-hi", "shared-1", "shared-2", "shared-3", "citer-1"] @@ -365,6 +372,7 @@ fn orders_by_ordinal_then_identity_and_reverses() { ..Selection::default() }, 1, + test_cursor_key(), ) .unwrap(); assert_eq!( @@ -384,7 +392,7 @@ fn pages_exactly_once_and_refuses_a_stale_or_mismatched_cursor() { }; let mut seen = Vec::new(); for _ in 0..10 { - let page = select(&FixtureRowSet, &rows, &selection, 1).unwrap(); + let page = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); seen.extend(page.rows.iter().map(|r| r.record_key.to_string())); match page.next_cursor { Some(cursor) => selection.cursor = Some(cursor), @@ -396,7 +404,7 @@ fn pages_exactly_once_and_refuses_a_stale_or_mismatched_cursor() { vec!["a-lo", "a-eq", "b-hi", "shared-1", "shared-2", "shared-3", "citer-1"] ); - let first = select(&FixtureRowSet, &rows, &Selection { limit: Some(1), ..Selection::default() }, 1).unwrap(); + let first = select(&FixtureRowSet, &rows, &Selection { limit: Some(1), ..Selection::default() }, 1, test_cursor_key()).unwrap(); let cursor = first.next_cursor.expect("more than one row"); let stale = select( @@ -404,6 +412,7 @@ fn pages_exactly_once_and_refuses_a_stale_or_mismatched_cursor() { &rows, &Selection { limit: Some(1), cursor: Some(cursor.clone()), ..Selection::default() }, 2, + test_cursor_key(), ); assert!(matches!(stale, Err(SelectionRefusal::CursorStale { .. }))); @@ -412,6 +421,7 @@ fn pages_exactly_once_and_refuses_a_stale_or_mismatched_cursor() { &rows, &Selection { limit: Some(1), cursor: Some(cursor), descending: true, ..Selection::default() }, 1, + test_cursor_key(), ); assert!(matches!(mismatched, Err(SelectionRefusal::CursorInvalid { .. }))); @@ -420,6 +430,7 @@ fn pages_exactly_once_and_refuses_a_stale_or_mismatched_cursor() { &rows, &Selection { cursor: Some("not-base64!!".into()), ..Selection::default() }, 1, + test_cursor_key(), ); assert!(matches!(garbage, Err(SelectionRefusal::CursorInvalid { .. }))); } @@ -435,7 +446,7 @@ fn hops_one_edge_by_declared_role() { }), ..Selection::default() }; - let evaluation = select(&FixtureRowSet, &rows, &selection, 1).unwrap(); + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); assert_eq!(evaluation.rows.iter().map(Row::record_key).collect::>(), vec!["citer-1"]); assert_eq!(evaluation.edges.len(), 1); assert_eq!(evaluation.edges[0].role, "Design"); @@ -465,7 +476,7 @@ fn cites_role_excludes_a_second_reference_at_the_same_target() { }), ..Selection::default() }; - let evaluation = select(&FixtureRowSet, &rows, &selection, 1).unwrap(); + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); assert_eq!(evaluation.edges.len(), 1, "OtherRole's reference must not also match"); assert_eq!(evaluation.edges[0].role, "Design"); } @@ -483,6 +494,7 @@ fn cited_exists_is_the_one_negation() { ..Selection::default() }, 1, + test_cursor_key(), ) .unwrap(); assert_eq!(cited.rows.iter().map(Row::record_key).collect::>(), vec!["a-eq"]); @@ -496,6 +508,7 @@ fn cited_exists_is_the_one_negation() { ..Selection::default() }, 1, + test_cursor_key(), ) .unwrap(); let mut ids: Vec<&str> = uncited.rows.iter().map(Row::record_key).collect(); @@ -522,7 +535,7 @@ fn compares_numbers_at_the_boundary_for_all_four_operators() { }]), ..Selection::default() }; - let mut ids: Vec = select(&FixtureRowSet, &rows, &selection, 1) + let mut ids: Vec = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()) .unwrap() .rows .iter() @@ -555,7 +568,36 @@ fn refuses_an_edge_outside_its_declared_target() { cited: Some(Incoming { role: "narrow_ref".into(), exists: true }), ..Selection::default() }; - let result = select(&FixtureRowSet, &rows, &selection, 1); + let result = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()); + assert!(matches!(result, Err(SelectionRefusal::ReferenceOutsideTarget { .. }))); +} + +// R-T/F15: `cites` checks every edge a citer carries against its declared target, not only the +// one the citation's own role/key filters ask about. Before the fix, a `cites` selection that +// asked about the citer's legitimate "Design" edge never looked at its "narrow_ref" edge, so a +// corrupt "narrow_ref" (pointed at a schema outside its declared target) hid behind the filter. +#[test] +fn cites_refuses_a_row_carrying_an_unrelated_edge_outside_its_declared_target() { + let rows = vec![ + FixtureRow::leaf(leaf_a(), "a-eq", 1, "weapon", "5"), + FixtureRow::leaf(leaf_b(), "b-hi", 2, "shield", "9"), + FixtureRow { + schema_id: NARROW_CITER, + record_key: "bad-citer", + ordinal: 3, + kind: None, + mass: None, + references: vec![ + ("Design".to_string(), rr(leaf_a(), "a-eq"), None), + ("narrow_ref".to_string(), rr(leaf_b(), "b-hi"), None), + ], + }, + ]; + let selection = Selection { + cites: Some(Citation { target: rr(leaf_a(), "a-eq"), role: Some("Design".into()) }), + ..Selection::default() + }; + let result = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()); assert!(matches!(result, Err(SelectionRefusal::ReferenceOutsideTarget { .. }))); } @@ -598,7 +640,7 @@ fn cited_selections_page_the_citee_and_their_edges_survive_the_page_filter() { cited: Some(Incoming { role: "components".into(), exists: true }), ..Selection::default() }; - let evaluation = select(&FixtureRowSet, &rows, &selection, 1).unwrap(); + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); assert_eq!(evaluation.rows.iter().map(Row::record_key).collect::>(), vec!["part-1"]); assert_eq!(evaluation.edges.len(), 1, "the citee (part-1) is on the page, so its incoming edge survives"); assert_eq!(evaluation.edges[0].from.record_key, "assembly"); @@ -619,7 +661,7 @@ fn cites_selections_still_page_the_citer_and_key_edges_off_it() { cites: Some(Citation { target: rr(leaf_a(), "a-eq"), role: Some("Design".into()) }), ..Selection::default() }; - let evaluation = select(&FixtureRowSet, &rows, &selection, 1).unwrap(); + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); assert_eq!(evaluation.rows.iter().map(Row::record_key).collect::>(), vec!["citer-1"]); assert_eq!(evaluation.edges.len(), 1); assert_eq!(evaluation.edges[0].from.record_key, "citer-1"); @@ -640,7 +682,7 @@ fn matches_every_row_sharing_an_index_value() { }]), ..Selection::default() }; - let evaluation = select(&FixtureRowSet, &rows, &selection, 1).unwrap(); + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); let mut ids: Vec<&str> = evaluation.rows.iter().map(Row::record_key).collect(); ids.sort_unstable(); assert_eq!(ids, vec!["shared-1", "shared-2", "shared-3"]); @@ -859,7 +901,7 @@ fn evaluate_vector(vector: &Vector) -> Result<(Vec, u32, bool, Vec = evaluation .rows @@ -1147,7 +1189,7 @@ fn write_selection_vectors_for_the_reference() { for (name, selection) in cases { let bytes = rmp_serde::to_vec_named(&selection).expect("encodes"); let rows = all_fixture_rows(); - let evaluation = select(&FixtureRowSet, &rows, &selection, 1).expect("valid selection"); + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).expect("valid selection"); let ids: Vec = evaluation .rows .iter() @@ -1284,7 +1326,7 @@ fn unused_operator_reference(op: SelectionOperator) -> &'static str { fn select_refuses_an_invalid_selection_instead_of_evaluating_it() { let rows = base_rows(); let selection = Selection { schemas: Some(Vec::new()), ..Selection::default() }; - let result = select(&FixtureRowSet, &rows, &selection, 1); + let result = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()); assert!(matches!(result, Err(SelectionRefusal::Invalid(ref invalid)) if invalid.field == "schemas")); } @@ -1330,14 +1372,195 @@ fn cursor_digest_does_not_collide_on_differently_split_lists() { ..Selection::default() }; assert_ne!( - Cursor::compute_digest(&one_joined_value), - Cursor::compute_digest(&two_split_values), + Cursor::compute_digest(&one_joined_value, test_cursor_key()), + Cursor::compute_digest(&two_split_values, test_cursor_key()), "a length-prefixed digest must not let list-splitting collide" ); let one_key = Selection { keys: Some(vec!["a,b".into()]), ..Selection::default() }; let two_keys = Selection { keys: Some(vec!["a".into(), "b".into()]), ..Selection::default() }; - assert_ne!(Cursor::compute_digest(&one_key), Cursor::compute_digest(&two_keys)); + assert_ne!( + Cursor::compute_digest(&one_key, test_cursor_key()), + Cursor::compute_digest(&two_keys, test_cursor_key()) + ); +} + +// ------------------------------------------------------------------------------------------ +// R-O: the cursor is keyed. A forged cursor, a cursor minted by another process, and a record +// key carrying the pre-fix delimiter character are all refused or round-trip correctly under the +// keyed, length-prefixed cursor. +// ------------------------------------------------------------------------------------------ + +#[test] +fn cursor_forged_without_the_key_is_refused() { + let rows = base_rows(); + let selection = Selection { limit: Some(1), ..Selection::default() }; + let page = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + let genuine = page.next_cursor.expect("more than one row"); + + // A forgery attempt: decode the genuine cursor, flip one byte inside its digest (the tail of + // the length-prefixed body), and re-encode - the shape (five length-prefixed fields) stays + // valid, only the digest content is wrong, exactly what an attacker guessing the selection's + // shape without the key would produce. + let mut bytes = base64::Engine::decode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + &genuine, + ) + .expect("genuine cursor decodes"); + let last = bytes.len() - 1; + bytes[last] ^= 0xFF; + let forged = base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, &bytes); + + let result = select( + &FixtureRowSet, + &rows, + &Selection { limit: Some(1), cursor: Some(forged), ..Selection::default() }, + 1, + test_cursor_key(), + ); + assert!(matches!(result, Err(SelectionRefusal::CursorInvalid { .. }))); +} + +#[test] +fn cursor_minted_by_another_process_is_refused() { + let rows = base_rows(); + let selection = Selection { limit: Some(1), ..Selection::default() }; + let matched = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + let last_row = matched.rows.last().expect("more than one row"); + + // A different process holds a different random key (R-O) - this cursor is genuine, just not + // minted under the key the answering server in this test holds. + let other_process_key = CursorKey::random(); + let cursor = Cursor::mint(1, last_row, &selection, &other_process_key); + + let result = select( + &FixtureRowSet, + &rows, + &Selection { limit: Some(1), cursor: Some(cursor), ..Selection::default() }, + 1, + test_cursor_key(), + ); + assert!(matches!(result, Err(SelectionRefusal::CursorInvalid { .. }))); +} + +// F6: the pre-fix C# cursor used U+241F as its field delimiter, so a record key carrying that +// exact character broke the server's own cursor. R-O's length-prefixed body has no delimiter +// character at all, so this (and any other character a record key might carry) round-trips. +#[test] +fn cursor_round_trips_a_record_key_carrying_the_pre_fix_delimiter_character() { + let row = FixtureRow::leaf(leaf_a(), "row-\u{241F}-with-the-old-delimiter", 7, "weapon", "5"); + let selection = Selection::default(); + let cursor_text = Cursor::mint(9, &row, &selection, test_cursor_key()); + let cursor = Cursor::parse(&cursor_text).expect("length-prefixed body decodes"); + assert_eq!(cursor.as_of, 9); + assert_eq!(cursor.ordinal, 7); + assert_eq!(cursor.schema_id, leaf_a()); + assert_eq!(cursor.record_key, "row-\u{241F}-with-the-old-delimiter"); +} + +// ------------------------------------------------------------------------------------------ +// R-N: refusals are wire messages. `error` stays the human string every peer already reads; +// `code`/`details` are additive, decoded from the bytes a peer actually receives. +// ------------------------------------------------------------------------------------------ + +#[test] +fn selection_invalid_refusal_carries_its_code_and_field_on_the_wire() { + let refusal = SelectionRefusal::from( + validate(&Selection { schemas: Some(vec![]), ..Selection::default() }, &FixtureRowSet) + .expect_err("empty schemas is refused"), + ); + let message = refusal.to_wire_message(); + let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0) + .expect("encodes"); + let decoded = decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0) + .expect("decodes"); + match decoded { + CultNetMessage::Error { error, code, details } => { + assert!(!error.is_empty()); + assert_eq!(code.as_deref(), Some("selection_invalid")); + assert_eq!(details.as_ref().and_then(|d| d.get("field")).map(String::as_str), Some("schemas")); + } + other => panic!("expected Error, got {other:?}"), + } +} + +#[test] +fn cursor_stale_refusal_carries_its_code_and_as_of_on_the_wire() { + let refusal = SelectionRefusal::CursorStale { as_of: 1, current: 2 }; + let message = refusal.to_wire_message(); + let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0) + .expect("encodes"); + let decoded = decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0) + .expect("decodes"); + match decoded { + CultNetMessage::Error { code, details, .. } => { + assert_eq!(code.as_deref(), Some("cursor_stale")); + let details = details.expect("cursor_stale carries details"); + assert_eq!(details.get("asOf").map(String::as_str), Some("1")); + assert_eq!(details.get("current").map(String::as_str), Some("2")); + } + other => panic!("expected Error, got {other:?}"), + } +} + +#[test] +fn cursor_invalid_refusal_carries_its_code_on_the_wire() { + let refusal = SelectionRefusal::CursorInvalid { message: "The cursor does not decode.".into() }; + let message = refusal.to_wire_message(); + let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0) + .expect("encodes"); + let decoded = decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0) + .expect("decodes"); + match decoded { + CultNetMessage::Error { code, .. } => assert_eq!(code.as_deref(), Some("cursor_invalid")), + other => panic!("expected Error, got {other:?}"), + } +} + +#[test] +fn reference_outside_target_refusal_carries_its_code_and_edge_on_the_wire() { + let refusal = SelectionRefusal::ReferenceOutsideTarget { + from_schema_id: citer().to_string(), + from_key: "citer-1".to_string(), + role: "narrow_ref".to_string(), + to_schema_id: leaf_b().to_string(), + to_key: "b-hi".to_string(), + }; + let message = refusal.to_wire_message(); + let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0) + .expect("encodes"); + let decoded = decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0) + .expect("decodes"); + match decoded { + CultNetMessage::Error { code, details, .. } => { + assert_eq!(code.as_deref(), Some("reference_outside_target")); + let details = details.expect("reference_outside_target carries details"); + assert_eq!(details.get("role").map(String::as_str), Some("narrow_ref")); + assert_eq!(details.get("toSchemaId").map(String::as_str), Some(leaf_b())); + assert_eq!(details.get("toKey").map(String::as_str), Some("b-hi")); + } + other => panic!("expected Error, got {other:?}"), + } +} + +// A v0 peer that has never heard of `code`/`details` must still decode `error` unchanged - the +// legacy array wire has no slot for the new fields at all (R-N). +#[test] +fn error_message_stays_decodable_on_the_legacy_array_wire_without_code_or_details() { + let refusal = SelectionRefusal::CursorInvalid { message: "The cursor does not decode.".into() }; + let message = refusal.to_wire_message(); + let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::GameCultNetworkingV0) + .expect("encodes"); + let decoded = decode_cultnet_message_from_slice(&bytes, CultNetWireContract::GameCultNetworkingV0) + .expect("decodes"); + match decoded { + CultNetMessage::Error { error, code, details } => { + assert!(!error.is_empty()); + assert_eq!(code, None); + assert_eq!(details, None); + } + other => panic!("expected Error, got {other:?}"), + } } // ------------------------------------------------------------------------------------------ @@ -1352,7 +1575,7 @@ fn schemas_reaches_a_schema_by_its_unversioned_name_alias() { schemas: Some(vec![leaf_a_name_alias().into()]), ..Selection::default() }; - let evaluation = select(&FixtureRowSet, &rows, &selection, 1).unwrap(); + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); let mut ids: Vec<&str> = evaluation.rows.iter().map(Row::record_key).collect(); ids.sort_unstable(); assert_eq!(ids, vec!["a-eq", "a-lo", "shared-1", "shared-3"], "every leaf_a row, by name alias"); @@ -1369,7 +1592,7 @@ fn schemas_does_not_reach_a_schema_by_a_hash_shaped_alias() { schemas: Some(vec![leaf_a_hash_alias().into()]), ..Selection::default() }; - let evaluation = select(&FixtureRowSet, &rows, &selection, 1).unwrap(); + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); assert!(evaluation.rows.is_empty(), "a hash-shaped alias must not resolve to leaf_a"); } @@ -1397,7 +1620,7 @@ fn cites_target_resolves_through_the_name_alias_matcher() { cites: Some(Citation { target: rr(leaf_a_name_alias(), "a-eq"), role: Some("Design".into()) }), ..Selection::default() }; - let evaluation = select(&FixtureRowSet, &rows, &selection, 1).unwrap(); + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); assert_eq!(evaluation.rows.iter().map(Row::record_key).collect::>(), vec!["citer-1"]); } @@ -1442,7 +1665,7 @@ fn schema_alias_module_is_reachable_from_the_crate_root() { fn matched_is_the_total_count_not_the_page_count() { let rows = base_rows(); let selection = Selection { limit: Some(2), ..Selection::default() }; - let evaluation = select(&FixtureRowSet, &rows, &selection, 1).unwrap(); + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); assert_eq!(evaluation.rows.len(), 2, "the page is clipped to the limit"); assert_eq!(evaluation.matched, 7, "matched counts every row base_rows() carries"); } @@ -1481,7 +1704,7 @@ fn select_page_header_projection_carries_no_payload_in_rows_or_edges() { ..Selection::default() }; let page: SelectionPage = - select_page(&FixtureRowSet, &rows, &selection, 1, document_record_for).unwrap(); + select_page(&FixtureRowSet, &rows, &selection, 1, test_cursor_key(), document_record_for).unwrap(); assert!(page.documents.is_none()); let headers = page.headers.expect("header projection carries headers"); assert_eq!(headers.len(), 1); @@ -1504,7 +1727,7 @@ fn select_page_document_projection_carries_payload_in_rows_and_edges() { ..Selection::default() }; let page: SelectionPage = - select_page(&FixtureRowSet, &rows, &selection, 1, document_record_for).unwrap(); + select_page(&FixtureRowSet, &rows, &selection, 1, test_cursor_key(), document_record_for).unwrap(); assert!(page.headers.is_none()); let documents = page.documents.expect("document projection carries documents"); assert_eq!(documents.len(), 1); @@ -1518,7 +1741,7 @@ fn select_page_document_projection_carries_payload_in_rows_and_edges() { fn select_page_matched_is_the_total_count() { let rows = base_rows(); let selection = Selection { limit: Some(2), ..Selection::default() }; - let page: SelectionPage = select_page(&FixtureRowSet, &rows, &selection, 1, document_record_for).unwrap(); + let page: SelectionPage = select_page(&FixtureRowSet, &rows, &selection, 1, test_cursor_key(), document_record_for).unwrap(); assert_eq!(page.matched, 7); assert_eq!(page.headers.unwrap().len(), 2); }