From 2ecb450f8025300a6c0dcd061b24c10ec89c1747 Mon Sep 17 00:00:00 2001 From: Eureka Date: Thu, 12 Nov 2026 00:50:27 +0000 Subject: [PATCH] Fetch wire: chat_fetch request/receipt documents, Chat::fetch, RudpChat::fetch, Bifrost persona-chat-v7 bytes as persona-chat-v9 First half of cut-persona-save-attachment.r7: the 4-slot request, the 5-slot receipt (nested ChatImage) with its one validity table (Stored with nil receipt allowed), the 14 outcome words, ReceiptMode, and the exchange() fetch beside open_direct including UnknownKind -> MouthRefused. Fixtures copied byte for byte from Bifrost 3a6b45d. Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/chat_client.rs | 159 ++++++++++++++++++ crates/mind-body/src/chat_client/tests.rs | 68 ++++++++ crates/mind-body/src/chat_rudp.rs | 21 ++- crates/mind-body/src/heartbeat/tests.rs | 4 + crates/mind-body/src/persona/tests.rs | 4 + crates/mind-body/tests/chat_rudp.rs | 22 ++- ....persona.chat_fetch_receipt.v1.refused.hex | 1 + ...sona.chat_fetch_receipt.v1.refused.msgpack | 1 + ....chat_fetch_receipt.v1.stored-existing.hex | 1 + ...t_fetch_receipt.v1.stored-existing.msgpack | Bin 0 -> 257 bytes ...rsona.chat_fetch_receipt.v1.stored-nil.hex | 1 + ...a.chat_fetch_receipt.v1.stored-nil.msgpack | Bin 0 -> 249 bytes ...na.chat_fetch_receipt.v1.stored-posted.hex | 1 + ...hat_fetch_receipt.v1.stored-posted.msgpack | Bin 0 -> 255 bytes ...hat_fetch_receipt.v1.stored-unverified.hex | 1 + ...fetch_receipt.v1.stored-unverified.msgpack | Bin 0 -> 259 bytes ...gamecult.persona.chat_fetch_request.v1.hex | 1 + ...cult.persona.chat_fetch_request.v1.msgpack | 1 + crates/mind-body/tests/public_surface.txt | 18 +- 19 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.refused.hex create mode 100644 crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.refused.msgpack create mode 100644 crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-existing.hex create mode 100644 crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-existing.msgpack create mode 100644 crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-nil.hex create mode 100644 crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-nil.msgpack create mode 100644 crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-posted.hex create mode 100644 crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-posted.msgpack create mode 100644 crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-unverified.hex create mode 100644 crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-unverified.msgpack create mode 100644 crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_request.v1.hex create mode 100644 crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_request.v1.msgpack diff --git a/crates/mind-body/src/chat_client.rs b/crates/mind-body/src/chat_client.rs index 1104e58..02c0dbd 100644 --- a/crates/mind-body/src/chat_client.rs +++ b/crates/mind-body/src/chat_client.rs @@ -39,6 +39,9 @@ pub const SEND_RECEIPT: &str = "gamecult.persona.chat_send_receipt.v2"; /// Her request that the mouth open a DM with a person, and its answer. pub const OPEN_DIRECT_REQUEST: &str = "gamecult.persona.chat_open_direct_request.v1"; pub const OPEN_DIRECT_RECEIPT: &str = "gamecult.persona.chat_open_direct_receipt.v1"; +/// Her request that the mouth fetch one event's image, verify it and post the receipt as her, and its answer. +pub const FETCH_REQUEST: &str = "gamecult.persona.chat_fetch_request.v1"; +pub const FETCH_RECEIPT: &str = "gamecult.persona.chat_fetch_receipt.v1"; pub const REFUSAL: &str = "gamecult.persona.chat_refusal.v1"; /// One earlier line of a room, delivered with a message for its context. @@ -688,6 +691,159 @@ impl TryFrom for ChatOpenDirectReceipt { } } +/// A request that the mouth fetch the image of `event_id` in `room` (an alias or room id) for `persona`: the reference is room and +/// event only. `request_id` makes a repeat the same fetch. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChatFetchRequest { + pub request_id: String, + pub persona: String, + pub room: String, + pub event_id: String, +} + +/// How the mouth answered a fetch, as its fourteen typed names. Whether the file may be fetched is the mouth's, derived from Matrix. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FetchOutcome { + /// Fetched, verified against the event's hash, stored. The receipt, if any, says whether it was posted. + Stored, + /// Fetched and stored but the event carried no hash: not verified, no receipt, never marked read. + StoredUnverified, + NotAMember, + BridgedRoom, + TooLarge, + TypeRefused, + NoAttachment, + HashMismatch, + RateLimited, + Brake, + PersonaMismatch, + UnknownPersona, + FetchFailed, + StoreFailed, +} + +impl FetchOutcome { + const ALL: [Self; 14] = [ + Self::Stored, + Self::StoredUnverified, + Self::NotAMember, + Self::BridgedRoom, + Self::TooLarge, + Self::TypeRefused, + Self::NoAttachment, + Self::HashMismatch, + Self::RateLimited, + Self::Brake, + Self::PersonaMismatch, + Self::UnknownPersona, + Self::FetchFailed, + Self::StoreFailed, + ]; + + /// The mouth's spelling: a MessagePack string. + pub fn name(self) -> &'static str { + match self { + Self::Stored => "Stored", + Self::StoredUnverified => "StoredUnverified", + Self::NotAMember => "NotAMember", + Self::BridgedRoom => "BridgedRoom", + Self::TooLarge => "TooLarge", + Self::TypeRefused => "TypeRefused", + Self::NoAttachment => "NoAttachment", + Self::HashMismatch => "HashMismatch", + Self::RateLimited => "RateLimited", + Self::Brake => "Brake", + Self::PersonaMismatch => "PersonaMismatch", + Self::UnknownPersona => "UnknownPersona", + Self::FetchFailed => "FetchFailed", + Self::StoreFailed => "StoreFailed", + } + } + + /// Whether the mouth stored a file for this outcome: the two that carry a room id and an image. + pub fn stored(self) -> bool { + matches!(self, Self::Stored | Self::StoredUnverified) + } +} + +impl Serialize for FetchOutcome { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.name()) + } +} + +impl<'de> Deserialize<'de> for FetchOutcome { + fn deserialize>(deserializer: D) -> Result { + let name = String::deserialize(deserializer)?; + Self::ALL.into_iter().find(|outcome| outcome.name() == name).ok_or_else(|| serde::de::Error::custom("the mouth named a fetch outcome this side does not know")) + } +} + +/// The receipt the mouth posted as the Persona for a `Stored` fetch, or found already posted. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReceiptMode { + Posted, + Existing, +} + +impl ReceiptMode { + pub fn name(self) -> &'static str { + match self { + Self::Posted => "Posted", + Self::Existing => "Existing", + } + } +} + +impl Serialize for ReceiptMode { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.name()) + } +} + +impl<'de> Deserialize<'de> for ReceiptMode { + fn deserialize>(deserializer: D) -> Result { + let name = String::deserialize(deserializer)?; + [Self::Posted, Self::Existing].into_iter().find(|mode| mode.name() == name).ok_or_else(|| serde::de::Error::custom("the mouth named a receipt mode this side does not know")) + } +} + +/// The mouth's answer to a fetch: `[request_id, outcome, room_id, image, receipt]`. `room_id` and `image` are present exactly for +/// `Stored` and `StoredUnverified`; `receipt` only with `Stored` (`Stored` with none is valid: nothing was posted, so it is not +/// marked read). A receipt that says otherwise does not decode. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "RawFetchReceipt")] +pub struct ChatFetchReceipt { + pub request_id: String, + pub outcome: FetchOutcome, + pub room_id: Option, + pub image: Option, + pub receipt: Option, +} + +#[derive(Deserialize)] +struct RawFetchReceipt { + request_id: String, + outcome: FetchOutcome, + room_id: Option, + image: Option, + receipt: Option, +} + +impl TryFrom for ChatFetchReceipt { + type Error = &'static str; + + fn try_from(raw: RawFetchReceipt) -> Result { + if raw.outcome.stored() != (raw.room_id.is_some() && raw.image.is_some()) || (!raw.outcome.stored() && (raw.room_id.is_some() || raw.image.is_some())) { + return Err("the fetch receipt's room and image do not match its outcome"); + } + if raw.receipt.is_some() && raw.outcome != FetchOutcome::Stored { + return Err("the fetch receipt posts a receipt for an outcome that is not Stored"); + } + Ok(Self { request_id: raw.request_id, outcome: raw.outcome, room_id: raw.room_id, image: raw.image, receipt: raw.receipt }) + } +} + /// A document as the mouth reads it: a positional tuple. pub fn encode(document: &T) -> Result> { rmp_serde::to_vec(document).context("the chat document does not encode") @@ -706,6 +862,9 @@ pub trait Chat { fn send(&self, request: &ChatSendRequest) -> Result; /// Ask the mouth to open a DM. A refusal is a receipt; only a transport fault or a mouth that does not serve the kind is an error. fn open_direct(&self, request: &ChatOpenDirectRequest) -> Result; + /// Ask the mouth to fetch, verify and receipt one event's image. A refusal is a receipt; only a transport fault or a mouth + /// that does not serve the kind is an error. + fn fetch(&self, request: &ChatFetchRequest) -> Result; /// Every delivery up to and including `through_seq` is answered; the mouth /// advances its sync position only now, so a start that dies first has its /// deliveries again. diff --git a/crates/mind-body/src/chat_client/tests.rs b/crates/mind-body/src/chat_client/tests.rs index 521aa17..9e759b5 100644 --- a/crates/mind-body/src/chat_client/tests.rs +++ b/crates/mind-body/src/chat_client/tests.rs @@ -185,6 +185,74 @@ fn open_direct_fixtures_round_trip() { golden(UNKNOWN_KIND_BYTES, &ChatRefusal { persona: "eureka".into(), refusal: Refusal::UnknownKind }); } +const FETCH_REQUEST_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_request.v1.msgpack"); +const FETCH_POSTED_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-posted.msgpack"); +const FETCH_EXISTING_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-existing.msgpack"); +const FETCH_NIL_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-nil.msgpack"); +const FETCH_UNVERIFIED_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-unverified.msgpack"); +const FETCH_REFUSED_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.refused.msgpack"); + +fn fetch_image() -> ChatImage { + let hash = "ab".repeat(32); + ChatImage { path: format!("{}/{hash}.png", "cd".repeat(32)), mime: "image/png".into(), size: 2048, sha256: hash } +} + +fn fetch_receipt(outcome: FetchOutcome, stored: bool, receipt: Option) -> ChatFetchReceipt { + ChatFetchReceipt { request_id: "req-f1".into(), outcome, room_id: stored.then(|| "!abc:gamecult.org".to_string()), image: stored.then(fetch_image), receipt } +} + +#[test] +fn mouth_fixtures_roundtrip() { + golden(FETCH_REQUEST_BYTES, &ChatFetchRequest { request_id: "req-f1".into(), persona: "eureka".into(), room: "#swarm:gamecult.org".into(), event_id: "$file1".into() }); + golden(FETCH_POSTED_BYTES, &fetch_receipt(FetchOutcome::Stored, true, Some(ReceiptMode::Posted))); + golden(FETCH_EXISTING_BYTES, &fetch_receipt(FetchOutcome::Stored, true, Some(ReceiptMode::Existing))); + golden(FETCH_NIL_BYTES, &fetch_receipt(FetchOutcome::Stored, true, None)); + golden(FETCH_UNVERIFIED_BYTES, &fetch_receipt(FetchOutcome::StoredUnverified, true, None)); + golden(FETCH_REFUSED_BYTES, &ChatFetchReceipt { request_id: "req-f1".into(), outcome: FetchOutcome::BridgedRoom, room_id: None, image: None, receipt: None }); + // The receipt is a 5-slot tuple whose image is its own 4-slot tuple. + assert_eq!(FETCH_POSTED_BYTES[0], 0x95); +} + +#[test] +fn the_receipt_checker_follows_the_one_table() { + use FetchOutcome::*; + let decodes = |receipt: &ChatFetchReceipt| { + let wire = (&receipt.request_id, receipt.outcome, &receipt.room_id, &receipt.image, receipt.receipt); + decode::(&encode(&wire).unwrap()).is_ok() + }; + for mode in [None, Some(ReceiptMode::Posted), Some(ReceiptMode::Existing)] { + assert!(decodes(&fetch_receipt(Stored, true, mode)), "Stored with {mode:?}"); + } + assert!(decodes(&fetch_receipt(StoredUnverified, true, None))); + assert!(!decodes(&fetch_receipt(StoredUnverified, true, Some(ReceiptMode::Posted))), "an unverified fetch posts no receipt"); + assert!(!decodes(&fetch_receipt(StoredUnverified, true, Some(ReceiptMode::Existing)))); + for outcome in FetchOutcome::ALL { + let stored = outcome.stored(); + assert!(!decodes(&fetch_receipt(outcome, !stored, None)), "{outcome:?} with the other half's ids"); + assert!(decodes(&fetch_receipt(outcome, stored, None)), "{outcome:?}"); + if outcome != Stored { + assert!(!decodes(&fetch_receipt(outcome, stored, Some(ReceiptMode::Posted))), "{outcome:?} with a receipt"); + } + let mut half = fetch_receipt(outcome, stored, None); + half.room_id = (!stored).then(|| "!r:x".to_string()); + assert!(!decodes(&half), "{outcome:?} with only a room id"); + } +} + +#[test] +fn every_fetch_outcome_is_spelled_as_the_mouth_spells_it_and_no_other_decodes() { + let words = ["Stored", "StoredUnverified", "NotAMember", "BridgedRoom", "TooLarge", "TypeRefused", "NoAttachment", "HashMismatch", "RateLimited", "Brake", "PersonaMismatch", "UnknownPersona", "FetchFailed", "StoreFailed"]; + assert_eq!(FetchOutcome::ALL.map(FetchOutcome::name), words); + for word in words { + assert_eq!(decode::(&encode(&word).unwrap()).unwrap().name(), word); + } + // The organ-local words are never on the wire. + for local in ["NoStore", "Unserved", "Unanswered", "stored", ""] { + assert!(decode::(&encode(&local).unwrap()).is_err(), "{local}"); + } + assert!(decode::(&encode(&"Skipped").unwrap()).is_err()); +} + #[test] fn every_outcome_is_spelled_as_the_mouth_spells_it() { let all = [ diff --git a/crates/mind-body/src/chat_rudp.rs b/crates/mind-body/src/chat_rudp.rs index ba1e8ac..3c507d8 100644 --- a/crates/mind-body/src/chat_rudp.rs +++ b/crates/mind-body/src/chat_rudp.rs @@ -30,7 +30,7 @@ use serde::de::DeserializeOwned; use sha2::{Digest, Sha256}; use crate::chat_client::{ - ACK, Chat, ChatAck, ChatDelivery, ChatOpenDirectReceipt, ChatOpenDirectRequest, ChatPending, ChatPendingRequest, ChatRead, ChatRefusal, MouthRefused, Refusal, reply_warnings, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, PENDING_REQUEST_V1, PENDING_REQUEST_V4, PENDING_V2, PENDING_V4, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, PENDING_V1, READ, REFUSAL, SEND_RECEIPT, + ACK, Chat, ChatAck, ChatDelivery, ChatFetchReceipt, ChatFetchRequest, ChatOpenDirectReceipt, ChatOpenDirectRequest, ChatPending, ChatPendingRequest, ChatRead, ChatRefusal, MouthRefused, Refusal, reply_warnings, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, PENDING_REQUEST_V1, PENDING_REQUEST_V4, PENDING_V2, PENDING_V4, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, FETCH_RECEIPT, FETCH_REQUEST, PENDING_V1, READ, REFUSAL, SEND_RECEIPT, SEND_REQUEST, decode, encode, }; @@ -229,6 +229,25 @@ impl Chat for RudpChat { Ok(receipt) } + fn fetch(&self, request: &ChatFetchRequest) -> Result { + // As an open: a mouth that does not serve the kind answers a refusal keyed by the persona, and the fetch is known not sent. + let p = self.persona.as_str(); + let answers = [(FETCH_RECEIPT, request.request_id.as_str()), (REFUSAL, p)]; + let (kind, payload) = self.exchange(FETCH_REQUEST, &request.request_id, request, Some(&answers))?.ok_or_else(|| anyhow!("no answer payload"))?; + if kind == REFUSAL { + let refusal: ChatRefusal = decode(&payload)?; + if refusal.persona != self.persona || refusal.refusal != Refusal::UnknownKind { + bail!("the mouth refused the fetch: {}", refusal.refusal.name()); + } + return Err(MouthRefused(refusal.refusal).into()); + } + let receipt: ChatFetchReceipt = decode(&payload)?; + if receipt.request_id != request.request_id { + bail!("the mouth answered another fetch"); + } + Ok(receipt) + } + fn ack(&self, through_seq: u64) -> Result<()> { let ack = ChatAck { persona: self.persona.clone(), through_seq }; self.exchange(ACK, &format!("{}:{through_seq}", self.persona), &ack, None).map(|_| ()) diff --git a/crates/mind-body/src/heartbeat/tests.rs b/crates/mind-body/src/heartbeat/tests.rs index 4acde14..7bc2328 100644 --- a/crates/mind-body/src/heartbeat/tests.rs +++ b/crates/mind-body/src/heartbeat/tests.rs @@ -101,6 +101,10 @@ impl Chat for FakeChat { panic!("the heartbeat opens nothing") } + fn fetch(&self, _: &crate::chat_client::ChatFetchRequest) -> Result { + panic!("the heartbeat fetches nothing") + } + fn ack(&self, _: u64) -> Result<()> { panic!("the heartbeat never acks a delivery") } diff --git a/crates/mind-body/src/persona/tests.rs b/crates/mind-body/src/persona/tests.rs index 124842c..897c131 100644 --- a/crates/mind-body/src/persona/tests.rs +++ b/crates/mind-body/src/persona/tests.rs @@ -193,6 +193,10 @@ impl Chat for FakeChat { Ok(ChatOpenDirectReceipt { request_id: request.request_id.clone(), outcome, room_id: landed.then(|| "!dm:chat".to_string()), event_id: landed.then(|| "$open".to_string()) }) } + fn fetch(&self, _: &crate::chat_client::ChatFetchRequest) -> Result { + panic!("no test asks the fake mouth to fetch yet") + } + fn read(&self, read: &ChatRead) -> Result<()> { if self.fail_read.get() { return Err(anyhow!("the mouth is down")); diff --git a/crates/mind-body/tests/chat_rudp.rs b/crates/mind-body/tests/chat_rudp.rs index 2ec7ae8..874eb33 100644 --- a/crates/mind-body/tests/chat_rudp.rs +++ b/crates/mind-body/tests/chat_rudp.rs @@ -13,7 +13,7 @@ use std::thread::JoinHandle; use std::time::Duration; use cultnet_rs::{CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions}; -use mind_body::chat_client::{self, ReplyLink, ReplyViolation, Basis, Form, ChatRefusal, MouthRefused, Refusal, REFUSAL, ACK, Chat, ChatAck, ChatOpenDirectReceipt, ChatOpenDirectRequest, DirectOutcome, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, ChatPending, ChatPendingRequest, ChatRead, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, PENDING_REQUEST_V1, PENDING_REQUEST_V4, PENDING_V1, PENDING_V2, PENDING_V4, READ, SEND_RECEIPT, SEND_REQUEST}; +use mind_body::chat_client::{self, ReplyLink, ReplyViolation, Basis, Form, ChatRefusal, MouthRefused, Refusal, REFUSAL, ACK, Chat, ChatAck, ChatFetchReceipt, ChatFetchRequest, ChatOpenDirectReceipt, ChatOpenDirectRequest, DirectOutcome, FetchOutcome, FETCH_RECEIPT, FETCH_REQUEST, ReceiptMode, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, ChatPending, ChatPendingRequest, ChatRead, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, PENDING_REQUEST_V1, PENDING_REQUEST_V4, PENDING_V1, PENDING_V2, PENDING_V4, READ, SEND_RECEIPT, SEND_REQUEST}; use mind_body::chat_rudp::{RudpChat, connection_id}; const ACK_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_ack.v1.msgpack"); @@ -35,6 +35,10 @@ const UNKNOWN_KIND_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v4/gamec const REFUSAL_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_refusal.v1.msgpack"); const OPENED_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v4/gamecult.persona.chat_open_direct_receipt.v1.opened.msgpack"); +// Bifrost 3a6b45d's own golden bytes for the fetch, copied unchanged as `persona-chat-v9/` (`tests/fixtures/persona-chat-v7/` there). +const FETCH_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v9/gamecult.persona.chat_fetch_request.v1.msgpack"); +const FETCH_POSTED_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-posted.msgpack"); + const KEY: &str = "connection-key-for-test"; const TAG: &str = "cultnet.transport.rudp.v0"; @@ -128,6 +132,7 @@ fn serve(key: &str, bind: &str, pending_kind: &'static str, pending_bytes: &'sta PENDING_REQUEST_V1 if pending_kind != PENDING && for_eureka(&chat_client::decode::(&document.payload).unwrap().persona) => Some(answer(pending_kind, "eureka", pending_bytes)), SEND_REQUEST if for_eureka(&chat_client::decode::(&document.payload).unwrap().persona) => Some(answer(SEND_RECEIPT, &document.record_key, SEND_RECEIPT_BYTES)), OPEN_DIRECT_REQUEST if for_eureka(&chat_client::decode::(&document.payload).unwrap().persona) => Some(answer(open.0, open.1.unwrap_or(&document.record_key), open.2)), + FETCH_REQUEST if for_eureka(&chat_client::decode::(&document.payload).unwrap().persona) => Some(answer(FETCH_RECEIPT, &document.record_key, FETCH_POSTED_BYTES)), _ => None, }; if let Some(reply) = reply { @@ -332,6 +337,21 @@ fn an_open_crosses_a_real_rudp_session_in_the_mouths_bytes_and_a_receipt_for_ano assert_eq!(seen[0], (OPEN_DIRECT_REQUEST.to_string(), OPEN_REQUEST_BYTES.to_vec())); } +#[test] +fn a_fetch_crosses_a_real_rudp_session_in_the_mouths_bytes_and_a_receipt_for_another_fetch_is_refused() { + let mouth = mouth(KEY); + let chat = client(&mouth, KEY, "eureka", Duration::from_secs(5)); + let request = chat_client::decode::(FETCH_REQUEST_BYTES).unwrap(); + let receipt = chat.fetch(&request).unwrap(); + assert_eq!(receipt, chat_client::decode::(FETCH_POSTED_BYTES).unwrap()); + assert_eq!((receipt.outcome, receipt.receipt), (FetchOutcome::Stored, Some(ReceiptMode::Posted))); + + let another = ChatFetchRequest { request_id: "req-other".into(), ..request }; + assert!(chat.fetch(&another).is_err(), "the answer names req-f1, not req-other"); + let seen = mouth.seen.lock().unwrap().clone(); + assert_eq!(seen[0], (FETCH_REQUEST.to_string(), FETCH_REQUEST_BYTES.to_vec())); +} + /// A mouth that answers every open with `reply`. fn opening_with(reply: OpenReply) -> Mouth { serve(KEY, "127.0.0.1:0", PENDING, PENDING_BYTES, reply) diff --git a/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.refused.hex b/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.refused.hex new file mode 100644 index 0000000..00c15fb --- /dev/null +++ b/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.refused.hex @@ -0,0 +1 @@ +95a67265712d6631ab42726964676564526f6f6dc0c0c0 diff --git a/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.refused.msgpack b/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.refused.msgpack new file mode 100644 index 0000000..385a8d2 --- /dev/null +++ b/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.refused.msgpack @@ -0,0 +1 @@ +•¦req-f1«BridgedRoomÀÀÀ \ No newline at end of file diff --git a/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-existing.hex b/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-existing.hex new file mode 100644 index 0000000..8048665 --- /dev/null +++ b/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-existing.hex @@ -0,0 +1 @@ +95a67265712d6631a653746f726564b1216162633a67616d6563756c742e6f726794d985636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463642f616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261622e706e67a9696d6167652f706e67cd0800d94061626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162a84578697374696e67 diff --git a/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-existing.msgpack b/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-existing.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..12b5389638c64a2d207fa56064fe6974c918636f GIT binary patch literal 257 zcmbQbtSGfmH_dQaa7lhqYRX2%#H3`a^u*lM8bia)>#gQn+}v2n3%M}wIZ{)Br`7^03|16jsO4v literal 0 HcmV?d00001 diff --git a/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-nil.hex b/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-nil.hex new file mode 100644 index 0000000..bfeda9e --- /dev/null +++ b/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-nil.hex @@ -0,0 +1 @@ +95a67265712d6631a653746f726564b1216162633a67616d6563756c742e6f726794d985636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463642f616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261622e706e67a9696d6167652f706e67cd0800d94061626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162c0 diff --git a/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-nil.msgpack b/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-nil.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..afc9494a78a89d497034777da5fccf089841b29a GIT binary patch literal 249 zcmbQbtSGfmH_dQaa7lhqYRX2%#H3`a^u*lM8bia)>#gQn+}v2n3!|`01Ss=TL1t6 literal 0 HcmV?d00001 diff --git a/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-posted.hex b/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-posted.hex new file mode 100644 index 0000000..f63ab7d --- /dev/null +++ b/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-posted.hex @@ -0,0 +1 @@ +95a67265712d6631a653746f726564b1216162633a67616d6563756c742e6f726794d985636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463642f616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261622e706e67a9696d6167652f706e67cd0800d94061626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162a6506f73746564 diff --git a/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-posted.msgpack b/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-posted.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..282f7cd5a4f11094d884ca1ccc82295b8a2d372a GIT binary patch literal 255 zcmbQbtSGfmH_dQaa7lhqYRX2%#H3`a^u*lM8bia)>#gQn+}v2n3%LIAiuaIH3a}%a$~Rn literal 0 HcmV?d00001 diff --git a/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-unverified.hex b/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-unverified.hex new file mode 100644 index 0000000..889de96 --- /dev/null +++ b/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-unverified.hex @@ -0,0 +1 @@ +95a67265712d6631b053746f726564556e7665726966696564b1216162633a67616d6563756c742e6f726794d985636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463642f616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261622e706e67a9696d6167652f706e67cd0800d94061626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162c0 diff --git a/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-unverified.msgpack b/crates/mind-body/tests/fixtures/persona-chat-v9/gamecult.persona.chat_fetch_receipt.v1.stored-unverified.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..0450ad2e2eccaa94000105f88e94ea7b3dec2957 GIT binary patch literal 259 zcmbQbtSGfmH_dQEa7lhqYD#EcS!z*cT4rj>M#aRWWUKVV+|=aKoD#kKqVy>@Ta!~L igZha{ltI0My!4fsxryni`asrM4u+c!lo^ & 'static str mind_body::chat_client: impl DirectOutcome mind_body::chat_client: impl DirectOutcome: fn name (self) -> & 'static str +mind_body::chat_client: impl FetchOutcome +mind_body::chat_client: impl FetchOutcome: fn name (self) -> & 'static str +mind_body::chat_client: impl FetchOutcome: fn stored (self) -> bool mind_body::chat_client: impl Form mind_body::chat_client: impl Form: fn name (self) -> & 'static str +mind_body::chat_client: impl ReceiptMode +mind_body::chat_client: impl ReceiptMode: fn name (self) -> & 'static str mind_body::chat_client: impl Refusal mind_body::chat_client: impl Refusal: fn name (self) -> & 'static str mind_body::chat_client: impl ReplyLink @@ -52,20 +61,27 @@ mind_body::chat_client: impl ReplyViolation mind_body::chat_client: impl ReplyViolation: fn name (self) -> & 'static str mind_body::chat_client: impl Serialize for Basis mind_body::chat_client: impl Serialize for DirectOutcome +mind_body::chat_client: impl Serialize for FetchOutcome mind_body::chat_client: impl Serialize for Form +mind_body::chat_client: impl Serialize for ReceiptMode mind_body::chat_client: impl Serialize for Refusal mind_body::chat_client: impl Serialize for ReplyLink +mind_body::chat_client: impl TryFrom < RawFetchReceipt > for ChatFetchReceipt mind_body::chat_client: impl TryFrom < RawOpenReceipt > for ChatOpenDirectReceipt mind_body::chat_client: impl std :: error :: Error for MouthRefused mind_body::chat_client: impl std :: fmt :: Display for MouthRefused mind_body::chat_client: impl< 'de > Deserialize < 'de > for Basis mind_body::chat_client: impl< 'de > Deserialize < 'de > for DirectOutcome +mind_body::chat_client: impl< 'de > Deserialize < 'de > for FetchOutcome mind_body::chat_client: impl< 'de > Deserialize < 'de > for Form +mind_body::chat_client: impl< 'de > Deserialize < 'de > for ReceiptMode mind_body::chat_client: impl< 'de > Deserialize < 'de > for Refusal mind_body::chat_client: impl< 'de > Deserialize < 'de > for ReplyLink -mind_body::chat_client: pub trait Chat { fn pending (& self , after_seq : u64) -> Result < ChatPending > ; fn send (& self , request : & ChatSendRequest) -> Result < ChatSendReceipt > ; fn open_direct (& self , request : & ChatOpenDirectRequest) -> Result < ChatOpenDirectReceipt > ; fn ack (& self , through_seq : u64) -> Result < () > ; fn read (& self , read : & ChatRead) -> Result < () > ; } +mind_body::chat_client: pub trait Chat { fn pending (& self , after_seq : u64) -> Result < ChatPending > ; fn send (& self , request : & ChatSendRequest) -> Result < ChatSendReceipt > ; fn open_direct (& self , request : & ChatOpenDirectRequest) -> Result < ChatOpenDirectReceipt > ; fn fetch (& self , request : & ChatFetchRequest) -> Result < ChatFetchReceipt > ; fn ack (& self , through_seq : u64) -> Result < () > ; fn read (& self , read : & ChatRead) -> Result < () > ; } mind_body::chat_client: struct ChatAck # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, through_seq: u64] mind_body::chat_client: struct ChatDelivery # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[seq: u64, room_alias: String, room_id: String, event_id: String, sender: String, sender_display: String, text: String, at_unix_ms: u64, context: Vec < ContextLine >, mentions: Vec < String >, is_direct: bool, image: Option < ChatImage >, basis: Option < Basis >, form: Form, reply_to: ReplyLink] +mind_body::chat_client: struct ChatFetchReceipt # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, outcome: FetchOutcome, room_id: Option < String >, image: Option < ChatImage >, receipt: Option < ReceiptMode >] +mind_body::chat_client: struct ChatFetchRequest # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, persona: String, room: String, event_id: String] mind_body::chat_client: struct ChatImage # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[path: String, mime: String, size: u64, sha256: String] mind_body::chat_client: struct ChatOpenDirectReceipt # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, outcome: DirectOutcome, room_id: Option < String >, event_id: Option < String >] mind_body::chat_client: struct ChatOpenDirectRequest # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, persona: String, user_id: String, text: String]