Desk reads: a read of an oversize fork is fork_too_large; vectors re-vendored from CultLib f6c9bf5f #40

Merged
eureka merged 4 commits from eureka-substrate/read-fork-too-large into main 2026-11-11 01:39:41 +00:00
5 changed files with 1321 additions and 9 deletions

View file

@ -7,7 +7,8 @@
//! bounds: one request per connection, one reply, a header over `DESK_MAX_REQUEST_BYTES` refused
//! before anything is allocated, one `DESK_CONNECTION_DEADLINE` for the whole connection (every
//! read and write spends what is left of it, so a peer that trickles bytes is cut off at the
//! deadline like one that is silent), a reply past `DESK_MAX_RESPONSE_BYTES` refused by name.
//! deadline like one that is silent), a reply past `DESK_MAX_RESPONSE_BYTES` refused by name (a read of one
//! fork past it is that fork's `fork_too_large`).
//!
//! The trust boundary of the reads (ruling `desk-read-visibility-whole-record`). The peer-uid check
//! IS the whole authentication: Huginn must be reachable only by Bifrost's peer-checked socket, so
@ -46,7 +47,7 @@ use cultnet_rs::{
};
use huginn_mind::desk_wire::{
DESK_MAX_REQUEST_BYTES, DESK_MAX_RESPONSE_BYTES, DESK_REQUEST_SCHEMA, DESK_REQUEST_SCHEMA_JSON, DESK_RESPONSE_SCHEMA,
DESK_RESPONSE_SCHEMA_JSON, DESK_SERVICE_ID, DeskResponse,
DESK_RESPONSE_SCHEMA_JSON, DESK_SERVICE_ID, DeskResponse, fork_too_large_read,
};
use huginn_mind::envelope::{OperationFailure, decode_desk_request, encode_desk_failure, encode_desk_response};
use huginn_mind::{MindRefusal, MindStore};
@ -321,7 +322,11 @@ pub(crate) fn respond(response: &DeskResponse, message_id: &str, operation: &str
};
bounded(reply, limit, |size| match size {
Ok(bytes) => {
let refusal = DeskResponse::Refused(MindRefusal::ResponseTooLarge { bytes: bytes as u64, limit: limit as u64 });
// A read of one fork past the cap is that fork's refusal; every other reply is the generic one.
let refusal = match response {
DeskResponse::Fork { entry, .. } => DeskResponse::NotStored(fork_too_large_read(entry.card.fork_id.as_str())),
_ => DeskResponse::Refused(MindRefusal::ResponseTooLarge { bytes: bytes as u64, limit: limit as u64 }),
};
encode_desk_response(message_id, operation, &refusal, runtime_id).unwrap_or_else(|error| not_encodable(format!("{error:#}")))
}
Err(detail) => not_encodable(detail),

View file

@ -590,8 +590,8 @@ fn refusal_of(response: &DeskResponse) -> Option<(String, String)> {
/// Three forks, the middle one's record grown over the socket until its card alone is past the page
/// budget. The list names it and its order (`fork_too_large`), the cursor `c<order>` walks on to the
/// older fork and ends at order 0, a READ of it never returns that code (an entry while it fits the
/// response bound, the door's named response-too-large past it), and `authority` on a read is still
/// older fork and ends at order 0, a READ of it never carries an order or cursor (an entry while it fits the
/// response bound, the fork-only `fork_too_large` past it), and `authority` on a read is still
/// refused.
#[test]
fn a_fork_too_large_to_page_is_named_by_the_list_and_never_by_the_read() {
@ -664,11 +664,11 @@ fn a_fork_too_large_to_page_is_named_by_the_list_and_never_by_the_read() {
let read = ask(&desk, "m-r", &fork_read("x", big));
let DeskResponse::Fork { entry, .. } = &read else { panic!("a read of the oversize fork is its entry, not a refusal: {read:?}") };
assert_eq!((entry.card.fork_id.as_str(), entry.card.order), (big, 1));
// Past DESK_MAX_RESPONSE_BYTES the read is the door's named response-too-large refusal, still not fork_too_large.
// Past DESK_MAX_RESPONSE_BYTES the read is the fork-only fork_too_large (no order, no cursor).
let mut more = 0_u32;
let too_big = loop {
let read = ask(&desk, "m-r", &fork_read("x", big));
if matches!(read, DeskResponse::Refused(_)) {
if matches!(read, DeskResponse::NotStored(_)) {
break read;
}
assert!(matches!(read, DeskResponse::Fork { .. }), "{read:?}");
@ -681,7 +681,8 @@ fn a_fork_too_large_to_page_is_named_by_the_list_and_never_by_the_read() {
}
assert!(matches!(ask(&desk, "m-w", &grow), DeskResponse::Answered { .. }));
};
assert!(matches!(too_big, DeskResponse::Refused(huginn_mind::MindRefusal::ResponseTooLarge { .. })), "{too_big:?}");
let (code, text) = refusal_of(&too_big).unwrap();
assert!(code == "fork_too_large" && text.contains(big) && !text.contains("order"), "{text}");
assert!(refusal_of(&ask(&desk, "m-g", &forks_page("x", Some("c2")))).is_some_and(|(code, _)| code == "fork_too_large"));
// (4) `authority` on a read is still refused whole (the contract's request_invalid is the door's payload-not-a-request).
@ -783,3 +784,46 @@ fn the_newest_fork_too_large_is_named_and_its_cursor_resumes_to_the_older_forks(
let rest = page_ids(ask(&desk, "m-p", &forks_page("x", Some("c2"))));
assert_eq!(rest, (vec!["eureka-state:question:Q2".to_string(), FORK.to_string()], None));
}
/// Grows `fork` over the socket until a READ of it no longer fits the reply cap; returns the raw reply.
fn grown_until_read_overflows(desk: &Path, hash: &str, fork: &str) -> CultNetMessage {
let note = "a long reason, ".repeat(60);
for sent in 1..400_u32 {
let reply = exchange(desk, &desk_bytes("m-read", &fork_read("x", fork))).expect("the door replied");
if !matches!(decode_desk_response(&reply).unwrap().1, Ok(DeskResponse::Fork { .. })) {
return reply;
}
let mut grow = answer("x", hash, &format!("00000000-0000-4000-8000-{sent:012}"));
if let DeskRequest::Answer { request, .. } = &mut grow {
request.fork_id = fork.into();
request.answer = Some(ForkAnswer { choice: None, note: Some(note.clone()) });
}
assert!(matches!(ask(desk, "m-w", &grow), DeskResponse::Answered { .. }));
}
panic!("the record does not grow")
}
/// A READ of a fork whose entry is past the reply cap is `fork_too_large` naming the fork, with no
/// order and no cursor; a fitting fork still reads; the list's refusal is unchanged; the generic
/// response-too-large stays for every other reply.
#[test]
fn a_read_of_an_oversize_fork_is_fork_too_large_naming_only_the_fork() {
let _serial = serial();
let (_root, _run, hash, desk, _daemon) = three_forks();
let big = "eureka-state:question:Q2";
let reply = grown_until_read_overflows(&desk, &hash, big);
let (id, response) = decode_desk_response(&reply).unwrap();
assert_eq!(id, "m-read");
let (code, text) = refusal_of(&response.unwrap()).expect("a not-stored refusal");
assert_eq!(code, "fork_too_large");
assert!(text.contains(big) && !text.contains("order") && !text.contains("cursor") && !text.contains(" c1"), "{text}");
// Fitting forks read, the others untouched.
for fork in [FORK, "eureka-state:question:Q3"] {
assert!(matches!(ask(&desk, "m-ok", &fork_read("x", fork)), DeskResponse::Fork { .. }));
}
// The list still names the fork and its order, and the cursor goes on.
let listed = ask(&desk, "m-l", &forks_page("x", Some("c2")));
let (code, text) = refusal_of(&listed).unwrap();
assert_eq!(code, "fork_too_large");
assert!(text.contains(big) && text.contains("order 1") && text.contains("c1"), "{text}");
}

View file

@ -390,6 +390,12 @@ fn fork_too_large(fork: &str, order: u64) -> ForkRefusal {
fork_refusal(ForkRefusalCode::ForkTooLarge, format!("fork_too_large: fork {fork} at order {order} is past the page budget by itself; {onward}"))
}
/// A READ of one fork whose entry is past the reply cap: names the fork, nothing else (a read has no
/// order to continue from and no cursor). The door raises it where it measures the reply.
pub fn fork_too_large_read(fork: &str) -> ForkRefusal {
fork_refusal(ForkRefusalCode::ForkTooLarge, format!("fork_too_large: fork {fork} is past the response bound by itself"))
}
fn body_invalid(field: &str) -> ForkRefusal {
fork_refusal(ForkRefusalCode::BodyInvalid, format!("body_invalid: the body does not satisfy gamecult.fork.answer_request.v0 ({field})"))
}

View file

@ -328,7 +328,8 @@ impl<S: MindStore> Mind<S> {
/// less the envelope margin); counts and stamp are unaffected. A page whose FIRST entry alone is past
/// the budget is the `fork_too_large` refusal, naming that fork and its order: the caller goes on
/// with the cursor `c<order>`, which returns the forks below it, so no older fork is unreachable.
/// The fork itself is still its whole entry on `desk_fork`. Nothing is dropped silently.
/// The fork itself is still its whole entry on `desk_fork`; if that entry is past the reply cap the door
/// answers the read with `fork_too_large` naming the fork (no order, no cursor), not `ResponseTooLarge`. Nothing is dropped silently.
///
/// The call is handed the call's `deciders` and no subject: they fill each card's `deciders` and
/// feed the showability gate, and gate nothing else. The record is whole, notes included
@ -810,6 +811,81 @@ mod tests {
assert_eq!(steps, 9);
}
/// CultLib f6c9bf5f: the short-page and too-large vectors. What Huginn can judge replays: the short
/// pages are strict nonempty prefixes that resume below their last entry, and every refusal document
/// (list step, read, doorRefusals) is a `ForkRefusal` that round-trips byte for byte with code
/// `fork_too_large`. HTTP statuses, `doorFaults` and `request_id_conflict` being answer-only are
/// Bifrost's mapping; Huginn has no judge for them.
#[test]
fn short_page_and_too_large_vectors_replay_byte_for_byte() {
let file = transport();
let mut short = 0;
let mut refusals = 0;
for scenario in file["listShortPages"].as_array().unwrap() {
let name = scenario["name"].as_str().unwrap();
for step in scenario["steps"].as_array().unwrap() {
if let Some(hex) = step["responseHex"].as_str() {
let bytes = bytes_of(hex);
let page: ForkList = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(encode(&page), bytes, "{name}");
if let Some(cursor) = page.next_cursor {
let last = page.forks.last().unwrap().card.order;
assert_eq!(cursor.order(), last, "{name}: next_cursor resumes below the last entry returned");
assert!((page.forks.len() as u64) < step["request"]["limit"].as_u64().unwrap(), "{name}: a short page");
short += 1;
}
} else {
let bytes = bytes_of(step["refusalHex"].as_str().unwrap());
let refusal: ForkRefusal = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(encode(&refusal), bytes, "{name}");
assert_eq!(refusal.code, ForkRefusalCode::ForkTooLarge);
refusals += 1;
}
}
}
assert_eq!((short, refusals), (2, 2));
let read = &file["readTooLarge"][0];
let bytes = bytes_of(read["refusalHex"].as_str().unwrap());
let refusal: ForkRefusal = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!((encode(&refusal), refusal.code), (bytes, ForkRefusalCode::ForkTooLarge));
let top: Value = serde_json::from_str(include_str!("../../vectors/fork-card-vectors.json")).unwrap();
let door = top["doorRefusals"].as_array().unwrap().iter().find(|row| row["name"] == "fork_too_large").unwrap();
let bytes = bytes_of(door["messagePackHex"].as_str().unwrap());
assert_eq!(encode(&rmp_serde::from_slice::<ForkRefusal>(&bytes).unwrap()), bytes);
}
/// The contract pins no exact text for `fork_too_large`: the vector's `error` string is
/// Huginn-independent, and a judge checks that the refusal names the entry the page would open
/// with. So for each vector case Huginn's refusal is code `fork_too_large`, its text contains the
/// fork id, the list form contains the order (and at order 0 never a `c0` cursor), and the read
/// form contains the fork id and no `order` or `cursor` wording.
#[test]
fn the_refusals_name_the_fork_the_vectors_name_and_by_the_contracts_form() {
let file = transport();
let mut listed = 0;
for scenario in file["listShortPages"].as_array().unwrap() {
for step in scenario["steps"].as_array().unwrap().iter().filter(|step| step.get("refusal").is_some()) {
let (fork_id, order) = (step["fork_id"].as_str().unwrap(), step["order"].as_u64().unwrap());
let refusal = fork_too_large(fork_id, order);
assert_eq!(refusal.code, ForkRefusalCode::ForkTooLarge);
assert!(refusal.error.contains(fork_id) && refusal.error.contains(&format!("order {order}")), "{}", refusal.error);
if order == 0 {
assert!(!refusal.error.contains("c0"), "{}", refusal.error);
}
listed += 1;
}
}
assert_eq!(listed, 2);
let order_zero = fork_too_large("gamecult-chat:question:q1", 0);
assert!(!order_zero.error.contains("c0") && order_zero.error.contains("end of the list"), "{}", order_zero.error);
for read in file["readTooLarge"].as_array().unwrap() {
let fork_id = read["fork_id"].as_str().unwrap();
let refusal = fork_too_large_read(fork_id);
assert_eq!(refusal.code, ForkRefusalCode::ForkTooLarge);
assert!(refusal.error.contains(fork_id) && !refusal.error.contains("order") && !refusal.error.contains("cursor"), "{}", refusal.error);
}
}
#[test]
fn invalid_entries_and_lists_do_not_decode_as_documents() {
let file = transport();

File diff suppressed because it is too large Load diff