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:
parent
5cf31d1e87
commit
5aac120a58
4 changed files with 462 additions and 79 deletions
|
|
@ -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()]),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue