CultNet selection cut1 fix batch 3, Rust half: R-N/R-O/R-T source and tests

The source and test changes for the previous commit's message - split out
because the harness-deletion staged from an earlier `git rm` committed
alone before these paths were added. Same ruling, same scope: R-N's wire
code/details, R-O's keyed length-prefixed cursor, R-T's every-edge cites
check, and their behavioural tests (including the hand-verified mutants:
reverting each of the three kills its own new test).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 19:25:38 +02:00
commit 5aac120a58
4 changed files with 462 additions and 79 deletions

View file

@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
details: Option<BTreeMap<String, String>>,
},
#[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<CultNetMessa
}),
4 => 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<rmpv::
legacy_bytes(session, "LoginSuccessMessage.Session")?,
]),
],
CultNetMessage::Error { error } => {
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()]),

View file

@ -259,7 +259,7 @@ fn hmac_sha256(key: &[u8], input: &[u8]) -> Result<Vec<u8>> {
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;
}

View file

@ -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<Sha256>;
// ---------------------------------------------------------------------------------------------
// Canonical decimal numbers (Q-J)
@ -922,6 +930,70 @@ impl From<SelectionInvalid> 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<String, String> {
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<R: Row + Clone>(
all_rows: &[R],
selection: &Selection,
as_of: u64,
cursor_key: &CursorKey,
) -> Result<Evaluation<R>, SelectionRefusal> {
validate(selection, row_set)?;
@ -1422,7 +1495,9 @@ pub fn select<R: Row + Clone>(
&& !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<R: Row + Clone>(
.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<R: Row + Clone>(
all_rows: &[R],
selection: &Selection,
as_of: u64,
cursor_key: &CursorKey,
document_record: impl Fn(&R) -> SelectionDocumentRecord,
) -> Result<SelectionPage, SelectionRefusal> {
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<R: Row + Clone>(
) -> Result<bool, SelectionRefusal> {
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<R: Row + Clone>(
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<R: Row + Clone>(
// 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<u8>);
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<R: Row>(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<R: Row>(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<Self, SelectionRefusal> {
@ -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 = <HmacSha256 as 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` `<byte length>:<value>` 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<Vec<String>> {
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)
}

View file

@ -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<CursorKey> = 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<_>>(),
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<_>>(), 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<_>>(), 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<String> = select(&FixtureRowSet, &rows, &selection, 1)
let mut ids: Vec<String> = 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<_>>(), 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<_>>(), 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<String>, u32, bool, Vec<Vecto
let selection: Selection =
rmp_serde::from_slice(&bytes).expect("vector selection bytes decode as MessagePack");
let rows = all_fixture_rows();
let evaluation = select(&FixtureRowSet, &rows, &selection, vector.as_of)
let evaluation = select(&FixtureRowSet, &rows, &selection, vector.as_of, test_cursor_key())
.map_err(|refusal| format!("select() refused: {refusal:?}"))?;
let ids: Vec<String> = 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<String> = 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<_>>(), 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);
}