diff --git a/crates/huginn-daemon/src/desk_door.rs b/crates/huginn-daemon/src/desk_door.rs index d5f868f..0c75486 100644 --- a/crates/huginn-daemon/src/desk_door.rs +++ b/crates/huginn-daemon/src/desk_door.rs @@ -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), diff --git a/crates/huginn-daemon/tests/desk_door.rs b/crates/huginn-daemon/tests/desk_door.rs index 15951d3..108d343 100644 --- a/crates/huginn-daemon/tests/desk_door.rs +++ b/crates/huginn-daemon/tests/desk_door.rs @@ -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` 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). diff --git a/crates/huginn-mind/src/desk_wire.rs b/crates/huginn-mind/src/desk_wire.rs index 53525f6..3a20331 100644 --- a/crates/huginn-mind/src/desk_wire.rs +++ b/crates/huginn-mind/src/desk_wire.rs @@ -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})")) } diff --git a/crates/huginn-mind/src/desk_wire/reads.rs b/crates/huginn-mind/src/desk_wire/reads.rs index 34de0b1..902c34f 100644 --- a/crates/huginn-mind/src/desk_wire/reads.rs +++ b/crates/huginn-mind/src/desk_wire/reads.rs @@ -328,7 +328,8 @@ impl Mind { /// 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`, 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