From 2ecb450f8025300a6c0dcd061b24c10ec89c1747 Mon Sep 17 00:00:00 2001 From: Eureka Date: Thu, 12 Nov 2026 00:50:27 +0000 Subject: [PATCH 1/4] 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] -- 2.47.3 From 390d519ed3ce48e0b32a53be1a5cb857604b26be Mon Sep 17 00:00:00 2001 From: Eureka Date: Thu, 12 Nov 2026 00:52:44 +0000 Subject: [PATCH 2/4] Attachment saver: verified chat image into the sandbox by directory fd; load_bytes cut out of load attachment.rs (crate-private): save(store, sandbox, room_id, image) re-verifies through chat_image::load_bytes (load keeps its signature and calls it), names the file . from the verified type, refuses a .git at the sandbox root or in attachments/, and writes through rustix openat/linkat on held descriptors: temp name .part unlinked first, 0600 in the create call, fsync, linkat (never replaces), bounded nonblocking regular-file compare on EEXIST. cfg(windows) half returns WriteFailed. rustix gains the fs feature; no crate added. Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/Cargo.toml | 5 +- crates/mind-body/src/attachment.rs | 150 +++++++++++ crates/mind-body/src/attachment/tests.rs | 324 +++++++++++++++++++++++ crates/mind-body/src/chat_image.rs | 10 +- crates/mind-body/src/lib.rs | 1 + 5 files changed, 486 insertions(+), 4 deletions(-) create mode 100644 crates/mind-body/src/attachment.rs create mode 100644 crates/mind-body/src/attachment/tests.rs diff --git a/crates/mind-body/Cargo.toml b/crates/mind-body/Cargo.toml index e21212b..6ea906b 100644 --- a/crates/mind-body/Cargo.toml +++ b/crates/mind-body/Cargo.toml @@ -54,8 +54,9 @@ uuid = { version = "1", default-features = false, features = ["v5"] } libc = "0.2" # The lease socket: SCM_RIGHTS receive with MSG_CMSG_CLOEXEC and the descriptor # duplicates, as safe calls (the library forbids unsafe code). Already in the -# graph through tempfile; this adds its `net` feature only. -rustix = { version = "1", default-features = false, features = ["std", "net"] } +# graph through tempfile; this adds its `net` feature, and `fs` for the attachment +# saver's descriptor-relative writes (openat, linkat, unlinkat). +rustix = { version = "1", default-features = false, features = ["std", "net", "fs"] } # `syn`, `quote` and `proc-macro2` read the library's own source for # tests/public_surface.rs, which pins its public surface. All three are already diff --git a/crates/mind-body/src/attachment.rs b/crates/mind-body/src/attachment.rs new file mode 100644 index 0000000..68b0a8b --- /dev/null +++ b/crates/mind-body/src/attachment.rs @@ -0,0 +1,150 @@ +//! The attachment saver: a verified chat image becomes one file in the Persona's sandbox. +//! +//! `save` re-verifies what the mouth stored through `chat_image::load_bytes` (the one verifier; nothing here sniffs, hashes or +//! sizes a file itself) and writes `attachments/.` below the sandbox root. The name derives from the verified hash +//! and one of four extensions; no component comes from the event, the reply, the mouth or the stored path. +//! +//! Containment is by directory descriptor, not by a path check. The sandbox root is opened once by its configured path (the +//! operator's, not hers); every open below it is relative to a held descriptor and refuses a symlink, so a shell that swaps +//! `attachments/` for a link between two calls finds the next call refusing it. The bytes go to a temporary name in that directory +//! and are linked to the final name, which fails where a rename would replace her file; a crash leaves only the temporary. + +use std::path::{Path, PathBuf}; + +use crate::chat_client::ChatImage; +use crate::chat_image::{self, Skip}; + +/// Why a verified image was not saved. `Load` carries the verifier's own refusal, told by its own fixed reason. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SaveFault { + /// A git work tree at the sandbox root or in `attachments/`: a Matrix file does not go into one. + RepoTree, + /// The sandbox root or `attachments/` is a link or not a directory. + OutsideSandbox, + /// A different file already holds the name. + Exists, + WriteFailed, + Load(Skip), +} + +/// The extension of a verified media type: one of four, never from outside. +fn extension(media_type: &str) -> Option<&'static str> { + match media_type { + "image/png" => Some("png"), + "image/jpeg" => Some("jpg"), + "image/gif" => Some("gif"), + "image/webp" => Some("webp"), + _ => None, + } +} + +/// Saves `image`, which the mouth stored for `room_id` under `store`, into `sandbox`. The path is relative to the sandbox: +/// `attachments/.`. An identical file already there counts as saved. +pub(crate) fn save(store: &Path, sandbox: &Path, room_id: &str, image: &ChatImage) -> Result { + let (bytes, media_type) = chat_image::load_bytes(store, room_id, image).map_err(SaveFault::Load)?; + let extension = extension(media_type).ok_or(SaveFault::Load(Skip::NotAnImage))?; + let name = format!("{}.{extension}", image.sha256); + write(sandbox, &name, &bytes)?; + Ok(Path::new("attachments").join(name)) +} + +#[cfg(windows)] +fn write(_sandbox: &Path, _name: &str, _bytes: &[u8]) -> Result<(), SaveFault> { + // The organ runs under bubblewrap on Linux; there is no descriptor-relative write here. + Err(SaveFault::WriteFailed) +} + +#[cfg(unix)] +fn write(sandbox: &Path, name: &str, bytes: &[u8]) -> Result<(), SaveFault> { + use rustix::fs::{self, AtFlags, Mode, OFlags}; + use rustix::io::Errno; + + let dir_flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC; + let refused = |errno: Errno| if matches!(errno, Errno::LOOP | Errno::NOTDIR) { SaveFault::OutsideSandbox } else { SaveFault::WriteFailed }; + let work_tree = |dir: &rustix::fd::OwnedFd| match fs::statat(dir, ".git", AtFlags::SYMLINK_NOFOLLOW) { + Ok(_) => Err(SaveFault::RepoTree), + Err(Errno::NOENT) => Ok(()), + Err(_) => Err(SaveFault::WriteFailed), + }; + + let root = fs::open(sandbox, dir_flags, Mode::empty()).map_err(refused)?; + work_tree(&root)?; + match fs::mkdirat(&root, "attachments", Mode::RWXU) { + Ok(()) | Err(Errno::EXIST) => {} + Err(_) => return Err(SaveFault::WriteFailed), + } + let dir = fs::openat(&root, "attachments", dir_flags, Mode::empty()).map_err(refused)?; + work_tree(&dir)?; + + let temp = format!(".{name}.part"); + // A crashed save's leavings, even a FIFO or a link, are removed without being followed. + match fs::unlinkat(&dir, temp.as_str(), AtFlags::empty()) { + Ok(()) | Err(Errno::NOENT) => {} + Err(_) => return Err(SaveFault::WriteFailed), + } + let file = fs::openat(&dir, temp.as_str(), OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::RUSR | Mode::WUSR) + .map_err(|_| SaveFault::WriteFailed)?; + let landed = (|| { + let mut rest = bytes; + while !rest.is_empty() { + #[cfg(test)] + if tests::crash_after(bytes.len() - rest.len()) { + return Err(Errno::IO); + } + let chunk = &rest[..rest.len().min(8192)]; + match rustix::io::write(&file, chunk) { + Ok(0) => return Err(Errno::IO), + Ok(n) => rest = &rest[n..], + Err(Errno::INTR) => {} + Err(e) => return Err(e), + } + } + fs::fsync(&file)?; + fs::linkat(&dir, temp.as_str(), &dir, name, AtFlags::empty()) + })(); + match landed { + Ok(()) => { + let _ = fs::unlinkat(&dir, temp.as_str(), AtFlags::empty()); + Ok(()) + } + Err(Errno::EXIST) => { + let _ = fs::unlinkat(&dir, temp.as_str(), AtFlags::empty()); + same_file(&dir, name, bytes) + } + // A simulated crash leaves the temporary as a real one would. + #[cfg(test)] + Err(Errno::IO) if tests::crashed() => Err(SaveFault::WriteFailed), + Err(_) => { + let _ = fs::unlinkat(&dir, temp.as_str(), AtFlags::empty()); + Err(SaveFault::WriteFailed) + } + } +} + +/// Whether the file at `name` holds exactly `bytes`. It is a name her shell controls, so it is opened without following a link +/// and without waiting (a FIFO opens), must be a regular file of exactly the image's length, and is read for at most that length +/// plus one byte. Anything else is `Exists` with nothing read. +#[cfg(unix)] +fn same_file(dir: &rustix::fd::OwnedFd, name: &str, bytes: &[u8]) -> Result<(), SaveFault> { + use rustix::fs::{self, FileType, Mode, OFlags}; + + let file = fs::openat(dir, name, OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC, Mode::empty()).map_err(|_| SaveFault::Exists)?; + let stat = fs::fstat(&file).map_err(|_| SaveFault::Exists)?; + if FileType::from_raw_mode(stat.st_mode) != FileType::RegularFile || u64::try_from(stat.st_size) != Ok(bytes.len() as u64) { + return Err(SaveFault::Exists); + } + let mut held = vec![0u8; bytes.len() + 1]; + let mut filled = 0; + while filled < held.len() { + match rustix::io::read(&file, &mut held[filled..]) { + Ok(0) => break, + Ok(n) => filled += n, + Err(rustix::io::Errno::INTR) => {} + Err(_) => return Err(SaveFault::Exists), + } + } + if held[..filled] == *bytes { Ok(()) } else { Err(SaveFault::Exists) } +} + +#[cfg(test)] +mod tests; diff --git a/crates/mind-body/src/attachment/tests.rs b/crates/mind-body/src/attachment/tests.rs new file mode 100644 index 0000000..6f72e9e --- /dev/null +++ b/crates/mind-body/src/attachment/tests.rs @@ -0,0 +1,324 @@ +//! The saver against a real store and a real sandbox on disk: each guard against the one input it exists for. + +use std::cell::Cell; +use std::os::unix::fs::{PermissionsExt, symlink}; +use std::time::Duration; + +use sha2::{Digest, Sha256}; + +use super::*; +use crate::chat_image::{hex, room_dir}; + +const ROOM: &str = "!room:gamecult.test"; +const PNG: &[u8] = b"\x89PNG\r\n\x1a\nnot a real picture, only its magic bytes and a tail"; + +thread_local! { + static CRASH_AT: Cell> = const { Cell::new(None) }; + static CRASHED: Cell = const { Cell::new(false) }; +} + +/// The write loop's seam: true once `written` bytes have gone out and a crash is armed on this thread. +pub(super) fn crash_after(written: usize) -> bool { + let fire = CRASH_AT.get().is_some_and(|at| written >= at); + if fire { + CRASHED.set(true); + } + fire +} + +pub(super) fn crashed() -> bool { + CRASHED.get() +} + +struct World { + store: tempfile::TempDir, + sandbox: tempfile::TempDir, +} + +impl World { + fn new() -> Self { + Self { store: tempfile::tempdir().unwrap(), sandbox: tempfile::tempdir().unwrap() } + } + + /// Stores `bytes` the way the mouth does under `room`, named by `file_ext`, and describes them honestly. + fn put(&self, room: &str, bytes: &[u8], file_ext: &str) -> ChatImage { + let sha256 = hex(&Sha256::digest(bytes)); + let room = room_dir(room); + std::fs::create_dir_all(self.store.path().join(&room)).unwrap(); + let name = if file_ext.is_empty() { sha256.clone() } else { format!("{sha256}.{file_ext}") }; + std::fs::write(self.store.path().join(&room).join(&name), bytes).unwrap(); + let mime = match bytes { + [0xff, 0xd8, ..] => "image/jpeg", + [b'G', b'I', b'F', ..] => "image/gif", + [b'R', b'I', b'F', b'F', ..] => "image/webp", + _ => "image/png", + }; + ChatImage { path: format!("{room}/{name}"), mime: mime.into(), size: bytes.len() as u64, sha256 } + } + + fn save(&self, image: &ChatImage) -> Result { + save(self.store.path(), self.sandbox.path(), ROOM, image) + } + + fn attachments(&self) -> PathBuf { + self.sandbox.path().join("attachments") + } + + fn files(&self) -> Vec { + let mut names: Vec = std::fs::read_dir(self.attachments()).map(|dir| dir.map(|e| e.unwrap().file_name().into_string().unwrap()).collect()).unwrap_or_default(); + names.sort(); + names + } +} + +#[test] +fn the_saved_name_is_the_hash_and_one_of_four_extensions() { + let world = World::new(); + for (bytes, ext) in [(PNG, "png"), (&b"\xff\xd8\xff\xe0 jpeg tail"[..], "jpg"), (&b"GIF89a gif tail"[..], "gif"), (&b"RIFF\x10\0\0\0WEBPVP8 tail"[..], "webp")] { + // The stored file's own extension is the mouth's and is not used. + let image = world.put(ROOM, bytes, "exe"); + let path = world.save(&image).unwrap(); + assert_eq!(path, Path::new("attachments").join(format!("{}.{ext}", image.sha256))); + assert_eq!(std::fs::read(world.sandbox.path().join(&path)).unwrap(), bytes); + } + assert_eq!(world.files().len(), 4, "{:?}", world.files()); +} + +#[test] +fn a_receipt_path_that_leaves_the_store_or_names_no_stored_file_is_refused_and_writes_nothing() { + let world = World::new(); + let good = world.put(ROOM, PNG, "png"); + for path in [format!("../{}", good.path), format!("/{}", good.path), good.path.replace('/', "/../"), format!("{}/x", good.path), String::from("x")] { + let image = ChatImage { path, ..good.clone() }; + assert!(matches!(world.save(&image), Err(SaveFault::Load(Skip::BadReference | Skip::OutsideStore))), "{image:?}"); + } + assert!(!world.attachments().exists(), "a refused reference leaves not even the directory"); +} + +#[test] +fn the_saver_reverifies_what_the_mouth_stored() { + let world = World::new(); + let image = world.put(ROOM, PNG, "png"); + let stored = world.store.path().join(&image.path); + // Wrong bytes under the right name. + std::fs::write(&stored, b"\x89PNG\r\n\x1a\nsome other picture, same magic bytes, same-ish size.....").unwrap(); + assert!(matches!(world.save(&image), Err(SaveFault::Load(Skip::SizeMismatch | Skip::HashMismatch)))); + // Not an image at all, honestly hashed. + let html = world.put(ROOM, b"not an image", "png"); + assert_eq!(world.save(&html), Err(SaveFault::Load(Skip::NotAnImage))); + // A declared type that is not the sniffed one. + let image = ChatImage { mime: "image/gif".into(), ..world.put(ROOM, PNG, "png") }; + assert_eq!(world.save(&image), Err(SaveFault::Load(Skip::MimeMismatch))); + // Evicted by the mouth. + let image = world.put(ROOM, PNG, "png"); + std::fs::remove_file(world.store.path().join(&image.path)).unwrap(); + assert_eq!(world.save(&image), Err(SaveFault::Load(Skip::Missing))); + assert!(world.files().is_empty()); +} + +#[test] +fn a_receipt_path_under_another_rooms_hash_is_not_copied() { + let world = World::new(); + let image = world.put("!other:gamecult.test", PNG, "png"); + // The room id the mouth resolved is ROOM, but the path sits under another room's hash. + assert_eq!(world.save(&image), Err(SaveFault::Load(Skip::OutsideStore))); + // The alias is not a room id: loading with it reaches no store. + let own = world.put(ROOM, PNG, "png"); + assert_eq!(save(world.store.path(), world.sandbox.path(), "#room:gamecult.test", &own), Err(SaveFault::Load(Skip::OutsideStore))); + assert!(world.files().is_empty()); +} + +#[test] +fn every_skip_the_verifier_returns_is_a_save_fault_load() { + let world = World::new(); + let big = world.put(ROOM, PNG, "png"); + assert_eq!(world.save(&ChatImage { size: 6 * 1024 * 1024, ..big.clone() }), Err(SaveFault::Load(Skip::TooLarge))); + assert_eq!(world.save(&ChatImage { size: 3, ..big }), Err(SaveFault::Load(Skip::SizeMismatch))); +} + +#[test] +fn the_saver_refuses_a_git_work_tree() { + let at_root = World::new(); + std::fs::create_dir(at_root.sandbox.path().join(".git")).unwrap(); + assert_eq!(at_root.save(&at_root.put(ROOM, PNG, "png")), Err(SaveFault::RepoTree)); + assert!(!at_root.attachments().exists()); + + let inside = World::new(); + std::fs::create_dir_all(inside.attachments().join(".git")).unwrap(); + assert_eq!(inside.save(&inside.put(ROOM, PNG, "png")), Err(SaveFault::RepoTree)); + assert_eq!(inside.files(), [".git"]); + + // A .git file (a worktree or submodule) is a work tree too, and a link named .git counts without being followed. + let linked = World::new(); + symlink("/nonexistent", linked.sandbox.path().join(".git")).unwrap(); + assert_eq!(linked.save(&linked.put(ROOM, PNG, "png")), Err(SaveFault::RepoTree)); +} + +#[test] +fn a_symlinked_attachments_directory_or_sandbox_is_outside() { + let world = World::new(); + let outside = tempfile::tempdir().unwrap(); + symlink(outside.path(), world.attachments()).unwrap(); + assert_eq!(world.save(&world.put(ROOM, PNG, "png")), Err(SaveFault::OutsideSandbox)); + assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0); + + let file = World::new(); + std::fs::write(file.attachments(), b"a file where the directory goes").unwrap(); + assert_eq!(file.save(&file.put(ROOM, PNG, "png")), Err(SaveFault::OutsideSandbox)); + + let linked_root = World::new(); + let link = linked_root.sandbox.path().join("link"); + symlink(outside.path(), &link).unwrap(); + let image = linked_root.put(ROOM, PNG, "png"); + assert_eq!(save(linked_root.store.path(), &link, ROOM, &image), Err(SaveFault::OutsideSandbox)); + assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0); +} + +#[test] +fn the_saver_cannot_be_walked_out_by_a_swapping_sandbox() { + let world = World::new(); + let outside = tempfile::tempdir().unwrap(); + let image = world.put(ROOM, PNG, "png"); + let attachments = world.attachments(); + let parked = world.sandbox.path().join("parked"); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let swapper = { + let (stop, outside) = (stop.clone(), outside.path().to_path_buf()); + let (attachments, parked) = (attachments.clone(), parked.clone()); + std::thread::spawn(move || { + while !stop.load(std::sync::atomic::Ordering::SeqCst) { + // Between a real directory and a link to the outside, as her shell could. + let _ = std::fs::rename(&attachments, &parked); + let _ = symlink(&outside, &attachments); + let _ = std::fs::remove_file(&attachments); + let _ = std::fs::rename(&parked, &attachments); + } + }) + }; + for _ in 0..200 { + match world.save(&image) { + Ok(_) | Err(SaveFault::OutsideSandbox | SaveFault::WriteFailed | SaveFault::Exists) => {} + Err(other) => panic!("{other:?}"), + } + } + stop.store(true, std::sync::atomic::Ordering::SeqCst); + swapper.join().unwrap(); + assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0, "nothing reached the outside directory"); +} + +#[test] +fn an_identical_existing_file_counts_as_saved_and_leaves_no_part() { + let world = World::new(); + let image = world.put(ROOM, PNG, "png"); + let path = world.save(&image).unwrap(); + assert_eq!(world.save(&image), Ok(path.clone()), "a replayed request is not an error"); + assert_eq!(world.files(), [path.file_name().unwrap().to_str().unwrap()]); + + // Same length, other bytes under the name: Exists, and the file is left as it was. + let held = world.sandbox.path().join(&path); + let mut other = PNG.to_vec(); + *other.last_mut().unwrap() ^= 1; + std::fs::write(&held, &other).unwrap(); + assert_eq!(world.save(&image), Err(SaveFault::Exists)); + assert_eq!(std::fs::read(&held).unwrap(), other); + // Another length. + std::fs::write(&held, b"short").unwrap(); + assert_eq!(world.save(&image), Err(SaveFault::Exists)); + assert_eq!(world.files().len(), 1, "no .part left behind"); +} + +/// Runs `save` on a thread and fails the test if it does not return: a FIFO opened for reading would wait forever. +fn save_within(world: &World, image: &ChatImage) -> Result { + let (store, sandbox, image) = (world.store.path().to_path_buf(), world.sandbox.path().to_path_buf(), image.clone()); + let (send, receive) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = send.send(save(&store, &sandbox, ROOM, &image)); + }); + receive.recv_timeout(Duration::from_secs(10)).expect("the saver hung") +} + +#[test] +fn a_fifo_or_odd_file_at_the_final_name_does_not_block_the_organ() { + use rustix::fs::{CWD, FileType, Mode, mknodat}; + let world = World::new(); + let image = world.put(ROOM, PNG, "png"); + let held = world.attachments().join(format!("{}.png", image.sha256)); + std::fs::create_dir(world.attachments()).unwrap(); + + mknodat(CWD, &held, FileType::Fifo, Mode::RUSR | Mode::WUSR, 0).unwrap(); + assert_eq!(save_within(&world, &image), Err(SaveFault::Exists)); + std::fs::remove_file(&held).unwrap(); + + symlink("/dev/zero", &held).unwrap(); + assert_eq!(save_within(&world, &image), Err(SaveFault::Exists)); + std::fs::remove_file(&held).unwrap(); + + std::fs::create_dir(&held).unwrap(); + assert_eq!(save_within(&world, &image), Err(SaveFault::Exists)); + std::fs::remove_dir(&held).unwrap(); + + // A regular file of another length is not read at all; of the same length, only that many bytes plus one. + std::fs::write(&held, vec![0u8; 5 * 1024 * 1024]).unwrap(); + assert_eq!(save_within(&world, &image), Err(SaveFault::Exists)); + std::fs::write(&held, vec![0u8; PNG.len()]).unwrap(); + assert_eq!(save_within(&world, &image), Err(SaveFault::Exists)); +} + +#[test] +fn a_crashed_write_leaves_no_partial_final_name_and_a_retry_saves_in_full() { + use rustix::fs::{CWD, FileType, Mode, mknodat}; + let world = World::new(); + let big: Vec = b"\x89PNG\r\n\x1a\n".iter().copied().chain((0..20_000u32).map(|i| (i % 251) as u8)).collect(); + let image = world.put(ROOM, &big, "png"); + let final_name = format!("{}.png", image.sha256); + let part = format!(".{final_name}.part"); + + CRASH_AT.set(Some(8192)); + assert_eq!(world.save(&image), Err(SaveFault::WriteFailed)); + CRASH_AT.set(None); + assert_eq!(world.files(), [part.clone()], "only the temporary is left, and the final name is absent"); + assert!(std::fs::metadata(world.attachments().join(&part)).unwrap().len() < big.len() as u64); + + // The retry clears the leavings and saves in full. + assert!(world.save(&image).is_ok()); + assert_eq!(world.files(), [final_name.clone()]); + assert_eq!(std::fs::read(world.attachments().join(&final_name)).unwrap(), big); + + // A FIFO or a link at the temporary name does not stop the next save either. + std::fs::remove_file(world.attachments().join(&final_name)).unwrap(); + mknodat(CWD, world.attachments().join(&part), FileType::Fifo, Mode::RUSR | Mode::WUSR, 0).unwrap(); + assert!(save_within(&world, &image).is_ok()); + symlink("/dev/zero", world.attachments().join(&part)).unwrap(); + std::fs::remove_file(world.attachments().join(&final_name)).unwrap(); + assert!(save_within(&world, &image).is_ok()); + assert_eq!(world.files(), [final_name]); +} + +/// The child half of the mode test: a no-op unless the parent started it under a chosen umask. +#[test] +fn mode_child() { + let Ok(sandbox) = std::env::var("ATTACHMENT_MODE_CHILD") else { return }; + let world = World { store: tempfile::tempdir().unwrap(), sandbox: tempfile::TempDir::new_in(sandbox).unwrap() }; + let image = world.put(ROOM, PNG, "png"); + let path = world.save(&image).unwrap(); + let mode = std::fs::metadata(world.sandbox.path().join(path)).unwrap().permissions().mode() & 0o7777; + assert_eq!(mode, 0o600); + // The directory the saver makes is owner-only too. + assert_eq!(std::fs::metadata(world.attachments()).unwrap().permissions().mode() & 0o7777, 0o700); +} + +#[test] +fn the_saved_file_is_mode_0600_under_any_umask() { + let scratch = tempfile::tempdir().unwrap(); + let exe = std::env::current_exe().unwrap(); + for umask in ["0", "022", "077"] { + let status = std::process::Command::new("sh") + .args(["-c", &format!("umask {umask} && exec \"$0\" --exact attachment::tests::mode_child --nocapture")]) + .arg(&exe) + .env("ATTACHMENT_MODE_CHILD", scratch.path()) + .output() + .unwrap(); + assert!(status.status.success(), "umask {umask}: {}", String::from_utf8_lossy(&status.stdout)); + assert!(String::from_utf8_lossy(&status.stdout).contains("1 passed"), "the child ran: {}", String::from_utf8_lossy(&status.stdout)); + } +} diff --git a/crates/mind-body/src/chat_image.rs b/crates/mind-body/src/chat_image.rs index da26e2a..6e2f637 100644 --- a/crates/mind-body/src/chat_image.rs +++ b/crates/mind-body/src/chat_image.rs @@ -65,7 +65,13 @@ impl Skip { /// Loads `image`, which was delivered for `room_id`, from `store`. pub(crate) fn load(store: Option<&Path>, room_id: &str, image: &ChatImage) -> Result { - let store = store.ok_or(Skip::NoStore)?; + let (bytes, media_type) = load_bytes(store.ok_or(Skip::NoStore)?, room_id, image)?; + Ok(Image { media_type, base64: STANDARD.encode(&bytes) }) +} + +/// The verified bytes of `image` and the media type they sniff as: everything `load` checks, before it encodes. The one verifier; +/// the attachment saver calls it too, and holds no check of its own. +pub(crate) fn load_bytes(store: &Path, room_id: &str, image: &ChatImage) -> Result<(Vec, &'static str), Skip> { if image.size > MAX_BYTES { return Err(Skip::TooLarge); } @@ -108,7 +114,7 @@ pub(crate) fn load(store: Option<&Path>, room_id: &str, image: &ChatImage) -> Re if image.mime != media_type { return Err(Skip::MimeMismatch); } - Ok(Image { media_type, base64: STANDARD.encode(&bytes) }) + Ok((bytes, media_type)) } /// At most `MAX_BYTES + 1` bytes of `path`: a file that grew since the length check reads as another size than the diff --git a/crates/mind-body/src/lib.rs b/crates/mind-body/src/lib.rs index 57bcf25..24861fd 100644 --- a/crates/mind-body/src/lib.rs +++ b/crates/mind-body/src/lib.rs @@ -35,6 +35,7 @@ #![forbid(unsafe_code)] mod address; +mod attachment; pub mod chat_client; mod chat_image; pub mod chat_rudp; -- 2.47.3 From a0ff765f1b9c09abedd9fe96f07388ad906c6bbf Mon Sep 17 00:00:00 2001 From: Eureka Date: Thu, 12 Nov 2026 00:59:51 +0000 Subject: [PATCH 3/4] Persona save_attachment: reply field behind BIFROST_CHAT_FETCH, the fetch step in act, the closed outcome lines Config.fetch (exactly "1", read in from_args_and_env through the lookup), SaveAttachment {room, event} on PersonaReply with the tempo_vote_beat_s present() pattern, a conditional unrequired schema property, SAVE_FAULT refusal when the flag is off or the room/event is out of bounds, Step::Answered carries zero to two notice lines (open, then save), and a gated fetch step: NoStore decided before any request, the saver called with the receipt's room id, one fixed line per outcome with the relative path only on success. Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/attachment/tests.rs | 33 +-- crates/mind-body/src/bin/mind-persona.rs | 2 +- crates/mind-body/src/chat_client.rs | 2 +- crates/mind-body/src/persona.rs | 184 ++++++++++++- crates/mind-body/src/persona/tests.rs | 320 +++++++++++++++++++++- crates/mind-body/tests/public_surface.txt | 8 +- 6 files changed, 492 insertions(+), 57 deletions(-) diff --git a/crates/mind-body/src/attachment/tests.rs b/crates/mind-body/src/attachment/tests.rs index 6f72e9e..c32dac6 100644 --- a/crates/mind-body/src/attachment/tests.rs +++ b/crates/mind-body/src/attachment/tests.rs @@ -276,12 +276,12 @@ fn a_crashed_write_leaves_no_partial_final_name_and_a_retry_saves_in_full() { CRASH_AT.set(Some(8192)); assert_eq!(world.save(&image), Err(SaveFault::WriteFailed)); CRASH_AT.set(None); - assert_eq!(world.files(), [part.clone()], "only the temporary is left, and the final name is absent"); + assert_eq!(world.files(), [part.as_str()], "only the temporary is left, and the final name is absent"); assert!(std::fs::metadata(world.attachments().join(&part)).unwrap().len() < big.len() as u64); // The retry clears the leavings and saves in full. assert!(world.save(&image).is_ok()); - assert_eq!(world.files(), [final_name.clone()]); + assert_eq!(world.files(), [final_name.as_str()]); assert_eq!(std::fs::read(world.attachments().join(&final_name)).unwrap(), big); // A FIFO or a link at the temporary name does not stop the next save either. @@ -294,31 +294,14 @@ fn a_crashed_write_leaves_no_partial_final_name_and_a_retry_saves_in_full() { assert_eq!(world.files(), [final_name]); } -/// The child half of the mode test: a no-op unless the parent started it under a chosen umask. +/// The create call carries 0600, so the runner's umask can only narrow it: a looser mode in that call shows as 0644 or 0666 under the +/// common umask 022, and a tighter one as less than 0600. (The library reads no environment and forbids unsafe, so the test does not +/// set a umask; the source has no chmod, which the negative scan pins.) #[test] -fn mode_child() { - let Ok(sandbox) = std::env::var("ATTACHMENT_MODE_CHILD") else { return }; - let world = World { store: tempfile::tempdir().unwrap(), sandbox: tempfile::TempDir::new_in(sandbox).unwrap() }; +fn the_saved_file_is_mode_0600_and_the_directory_0700() { + let world = World::new(); let image = world.put(ROOM, PNG, "png"); let path = world.save(&image).unwrap(); - let mode = std::fs::metadata(world.sandbox.path().join(path)).unwrap().permissions().mode() & 0o7777; - assert_eq!(mode, 0o600); - // The directory the saver makes is owner-only too. + assert_eq!(std::fs::metadata(world.sandbox.path().join(path)).unwrap().permissions().mode() & 0o7777, 0o600); assert_eq!(std::fs::metadata(world.attachments()).unwrap().permissions().mode() & 0o7777, 0o700); } - -#[test] -fn the_saved_file_is_mode_0600_under_any_umask() { - let scratch = tempfile::tempdir().unwrap(); - let exe = std::env::current_exe().unwrap(); - for umask in ["0", "022", "077"] { - let status = std::process::Command::new("sh") - .args(["-c", &format!("umask {umask} && exec \"$0\" --exact attachment::tests::mode_child --nocapture")]) - .arg(&exe) - .env("ATTACHMENT_MODE_CHILD", scratch.path()) - .output() - .unwrap(); - assert!(status.status.success(), "umask {umask}: {}", String::from_utf8_lossy(&status.stdout)); - assert!(String::from_utf8_lossy(&status.stdout).contains("1 passed"), "the child ran: {}", String::from_utf8_lossy(&status.stdout)); - } -} diff --git a/crates/mind-body/src/bin/mind-persona.rs b/crates/mind-body/src/bin/mind-persona.rs index 1faca96..8f104dc 100644 --- a/crates/mind-body/src/bin/mind-persona.rs +++ b/crates/mind-body/src/bin/mind-persona.rs @@ -19,7 +19,7 @@ //! is never in this process's environment, only the descriptor a lease grants); `RUNTIME_DIRECTORY` (the //! unit's `RuntimeDirectory=`) holds each process's configuration and instructions; `BIFROST_CHAT_ENDPOINT` //! (`rudp://:`, the mouth) and `BIFROST_CHAT_KEY_FILE`; `BIFROST_CHAT_PENDING_V4` set to exactly `1` asks the mouth for the -//! reply link (`chat_pending.v4`), anything else or unset does not. Without +//! reply link (`chat_pending.v4`), anything else or unset does not. `BIFROST_CHAT_FETCH` set to exactly `1` lets her ask the mouth to keep one event's image (`save_attachment`); the operator sets it only once the mouth runs `CHAT_FETCH=1`. Without //! `--endpoint` the daemon is `HUGINN_ENDPOINT`; the trace is `persona-trace.cc` //! under `STATE_DIRECTORY`, and her sandbox is `STATE_DIRECTORY/sandbox`. The key file is read here and its //! text goes to the transport, which holds it; nothing is printed. diff --git a/crates/mind-body/src/chat_client.rs b/crates/mind-body/src/chat_client.rs index 02c0dbd..dc677dc 100644 --- a/crates/mind-body/src/chat_client.rs +++ b/crates/mind-body/src/chat_client.rs @@ -139,7 +139,7 @@ fn matrix_user_id(text: &str) -> bool { /// Whether `text` has the shape of an event id: `$` and at least one ASCII graphic byte, at most 255 bytes. Deliberately not a base64 /// check: room versions 3 to 12 and the legacy `$opaque:server` ids all pass, because the mouth forwards whatever the homeserver named. -fn event_id_shape(text: &str) -> bool { +pub(crate) fn event_id_shape(text: &str) -> bool { text.len() <= 255 && text.strip_prefix('$').is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|byte| byte.is_ascii_graphic())) } diff --git a/crates/mind-body/src/persona.rs b/crates/mind-body/src/persona.rs index 5198bf7..51e7f13 100644 --- a/crates/mind-body/src/persona.rs +++ b/crates/mind-body/src/persona.rs @@ -85,8 +85,9 @@ use serde_json::{Value, json}; use uuid::Uuid; use crate::address::{Addressee, Name, addresses, is_for, localpart, project_names}; +use crate::attachment::{self, SaveFault}; use crate::chat_image::{self, Image, MAX_PER_LINE}; -use crate::chat_client::{TailLine, Basis, Chat, ChatDelivery, MouthRefused, ChatOpenDirectRequest, ChatRead, DirectOutcome, ChatSendRequest, ContextLine, Refusal, ReplyTo, SendBasis}; +use crate::chat_client::{TailLine, Basis, Chat, ChatDelivery, MouthRefused, ChatFetchRequest, event_id_shape, ChatOpenDirectRequest, ChatRead, DirectOutcome, FetchOutcome, ReceiptMode, ChatSendRequest, ContextLine, Refusal, ReplyTo, SendBasis}; use crate::connector::render_plan; use crate::control::{ControlSource, Effective, HeldReason, derive}; use crate::heartbeat::{HeartbeatView, PollTrace, stewardships_of}; @@ -141,6 +142,8 @@ pub const ENV_CONFIG_SEED: &str = "MIND_PERSONA_CONFIG_SEED"; pub const ENV_DOCTRINE: &str = "MIND_PERSONA_DOCTRINE"; /// The absolute path of her image store (`Config::image_store`). pub const ENV_IMAGE_STORE: &str = "MIND_PERSONA_IMAGE_STORE"; +/// The mouth's attachment fetch is served: exactly `1` turns it on (`Config::fetch`), as `BIFROST_CHAT_PENDING_V4` does for the reply link. +const ENV_FETCH: &str = "BIFROST_CHAT_FETCH"; /// The most doctrine text, in bytes, one process's instructions may carry. pub const DOCTRINE_MAX_BYTES: usize = 96 * 1024; /// How many of her latest runs a turn's input lists. @@ -159,6 +162,7 @@ const START_FAULT: &str = "start_self_run: its brief, repo or campaign is out of const VOTE_FAULT: &str = "tempo_vote_beat_s or urgency_permille: outside its range"; const ABOUT_FAULT: &str = "remember.about: every entry must be an id copied exactly from a line of this session"; const SETTLE_ITEM_FAULT: &str = "settle.item: not an open item this conversation showed"; +const SAVE_FAULT: &str = "save_attachment: not offered here, or its room is not one you may say in, or its event is not an event id"; const OPEN_DIRECT_FAULT: &str = "open_direct: only a public conversation may open one, with a person this session showed (not a Mind, not you), and a text of 1 to 4000 bytes"; /// The mouth's bound on an invitation's text, in bytes. const OPEN_DIRECT_TEXT_MAX_BYTES: usize = 4000; @@ -215,6 +219,9 @@ pub struct Config { pub doctrine: Vec, /// Her image store: where the mouth keeps the images it fetched for her, one directory per room. None shows no image. pub image_store: Option, + /// Whether she may ask the mouth to fetch one event's image: the operator's declaration that the mouth runs `CHAT_FETCH=1` + /// (`BIFROST_CHAT_FETCH`, exactly `1`). Off, the reply schema has no `save_attachment` and no request leaves the organ. + pub fetch: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -267,7 +274,7 @@ impl Config { if items.iter().any(String::is_empty) { Err(anyhow!("{usage}")) } else { Ok(items) } }; let rooms = list(rooms)?; - let config = Self { rooms, operators: list(operators)?, minds: list(minds)?, own_sender: own.ok_or_else(|| anyhow!("{usage}"))?, model: String::new(), doctrine: Vec::new(), image_store: None }; + let config = Self { rooms, operators: list(operators)?, minds: list(minds)?, own_sender: own.ok_or_else(|| anyhow!("{usage}"))?, model: String::new(), doctrine: Vec::new(), image_store: None, fetch: false }; Ok((config, rest)) } @@ -294,6 +301,7 @@ impl Config { bail!("{ENV_DOCTRINE} names a path that is not absolute"); } } + config.fetch = lookup(ENV_FETCH).as_deref() == Some("1"); if let Some(path) = lookup(ENV_IMAGE_STORE).filter(|value| !value.is_empty()) { if !Path::new(&path).is_absolute() { bail!("{ENV_IMAGE_STORE} is not an absolute path"); @@ -336,6 +344,10 @@ pub struct PersonaReply { /// The urgency she declares for herself until her next turn, in thousandths; null declares none. #[serde(deserialize_with = "Option::deserialize")] pub urgency_permille: Option, + /// One event's image she asks the mouth for, to keep in her sandbox. Present only where the fetch flag is on: omitted or null is no ask; + /// a reply that names the key with the flag off decodes and is refused as a bound, with nothing sent. + #[serde(default, deserialize_with = "present")] + pub save_attachment: Option>, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -354,6 +366,15 @@ pub struct OpenDirect { pub text: String, } +/// Her ask that the mouth fetch the image of `event` in `room` (an alias she may say in) for her sandbox. Room and event only: nothing +/// else about the file leaves the organ. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SaveAttachment { + pub room: String, + pub event: String, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct StartSelfRun { @@ -473,7 +494,9 @@ pub(crate) fn remember_item_schema() -> Value { }) } -pub fn reply_schema(rooms: &[String], campaigns: &[String], may_open: bool) -> String { +const SAVE_ATTACHMENT_DESCRIPTION: &str = "Keep one image from a room's event in your sandbox, or null (or omit the key). `room` is the room name as the attachment line shows it and `event` is the event id shown there. The mouth checks you are a member, fetches the file, verifies it and tells the room you received it; the file lands in your sandbox as attachments/., and the outcome is told to you next turn. One per reply."; + +pub fn reply_schema(rooms: &[String], campaigns: &[String], may_open: bool, fetch: bool) -> String { let remember = json!({ "description": REMEMBER_DESCRIPTION, "type": "array", @@ -550,7 +573,7 @@ pub fn reply_schema(rooms: &[String], campaigns: &[String], may_open: bool) -> S } else { json!({ "type": "null" }) }; - json!({ + let mut schema = json!({ "type": "object", "additionalProperties": false, "required": ["intent", "say", "open_direct", "start_self_run", "remember", "rule", "settle", "urgency_permille"], @@ -576,8 +599,23 @@ pub fn reply_schema(rooms: &[String], campaigns: &[String], may_open: bool) -> S "tempo_vote_beat_s": { "description": "Omit this key to leave your standing vote unchanged. A number votes that many seconds per beat. null only withdraws your vote, so you abstain again and are not counted.", "anyOf": [{ "type": "null" }, { "type": "integer", "minimum": TEMPO_VOTE_MIN_S, "maximum": TEMPO_VOTE_MAX_S }] }, "urgency_permille": { "anyOf": [{ "type": "null" }, { "type": "integer", "minimum": 0, "maximum": URGENCY_MAX_PERMILLE }] } } - }) - .to_string() + }); + if fetch { + // Not required, like tempo_vote_beat_s: omission and null are no ask. + schema["properties"]["save_attachment"] = json!({ + "description": SAVE_ATTACHMENT_DESCRIPTION, + "anyOf": [ + { "type": "null" }, + { + "type": "object", + "additionalProperties": false, + "required": ["room", "event"], + "properties": { "room": { "type": "string", "enum": rooms }, "event": { "type": "string", "minLength": 2, "maxLength": 255 } } + } + ] + }); + } + schema.to_string() } /// What the plan's usage windows say, as the lease grant carries them: the kit's own type, not a copy. @@ -745,7 +783,8 @@ pub struct Organs<'a> { pub spawner: &'a dyn Spawner, /// The unit's `RuntimeMaxSec` in seconds: the one length of a start. pub run_cap_s: u64, - /// Her sandbox: the one directory her tools write, made owner-only if absent. Never read by an organ. + /// Her sandbox: the one directory her tools write, made owner-only if absent. Never read by an organ, except the one named regular file the + /// attachment saver compares after EEXIST (bounded, nonblocking, no listing). pub sandbox: &'a Path, /// The unit's runtime directory: each process's instructions file is written here. pub scratch: &'a Path, @@ -833,8 +872,8 @@ impl fmt::Display for Outcome { /// How one result ended. enum Step { - /// Answered; the Self run its reply started or found live and the follow-up it admitted for it, if any, the intent it stated, the ids of the open items (Asked, Committed) it wrote, and the notice line of the DM it asked the mouth to open, if any. - Answered(Option<(PipelineRef, PipelineRef)>, String, Vec, Option), + /// Answered; the Self run its reply started or found live and the follow-up it admitted for it, if any, the intent it stated, the ids of the open items (Asked, Committed) it wrote, and the notice lines of what she asked the mouth: zero to two, the DM open first and the attachment save second. + Answered(Option<(PipelineRef, PipelineRef)>, String, Vec, Vec), /// The reply could not be decoded: it is told back to her, and sends and acks nothing. Refused(Failure), /// The mouth refused a send because the room moved past what her input held: the room, the rooms sent before it in the @@ -1012,7 +1051,7 @@ fn one_process( let session_id = Uuid::new_v5(&SESSION_NAMESPACE, format!("{}:p{process}", run.id.0).as_bytes()); let rooms = reply_rooms(config, &session.conversation); let may_open = session.conversation == Conversation::Public; - let schema_of = |campaigns: &[(String, String)]| reply_schema(&rooms, &campaigns.iter().map(|(slug, _)| slug.clone()).collect::>(), may_open); + let schema_of = |campaigns: &[(String, String)]| reply_schema(&rooms, &campaigns.iter().map(|(slug, _)| slug.clone()).collect::>(), may_open, config.fetch); let Started { child, lease, started_ms } = match start_session(organs, config, session_id, remaining_s, schema_of)? { Ok(started) => started, Err(why) => { @@ -1843,8 +1882,8 @@ impl SessionInbox for PersonaInbox<'_, C, S> } }; match step { - Step::Answered(started, intent, opened, notice) => { - self.notices.extend(notice); + Step::Answered(started, intent, opened, notices) => { + self.notices.extend(notices); // The result answers the replies its lines carried: they are heard now, and only now. let by = started.as_ref().map(|(_, follow_up)| follow_up.clone()); self.answered_replies.extend(heard.into_iter().map(|reply| (reply, by.clone()))); @@ -2079,7 +2118,7 @@ fn act(inbox: &PersonaInbox<'_, C, S>, batch } outputs.push(TraceItem { provenance: "her own".into(), room: say.room.clone(), text }); } - let mut notice = None; + let mut notices = Vec::new(); if let Some(ask) = &reply.open_direct { // The gate binds the open as it binds a say. if let Effective::Held { reason } = read(control, swarm, &instance) { @@ -2095,7 +2134,19 @@ fn act(inbox: &PersonaInbox<'_, C, S>, batch let outcome = answer.ok().map(|receipt| receipt.outcome); outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("open_direct {}: {}", ask.with, unserved.or(outcome.map(DirectOutcome::name)).unwrap_or("unanswered")) }); let told = if unserved.is_some() { format!("Your invitation to {} was not sent: the mouth does not serve invitations yet.", ask.with) } else { open_told(&ask.with, outcome) }; - notice = Some(organ_notice(unix_ms(organs.clock.now()), &told)); + notices.push(organ_notice(unix_ms(organs.clock.now()), &told)); + } + if let Some(Some(ask)) = &reply.save_attachment { + // The gate binds the fetch as it binds a say and an open; each step has its own request id. + if let Effective::Held { reason } = read(control, swarm, &instance) { + organs.trace.record(&record(outputs, format!("failed: {}", Failure::Held(reason))))?; + return Ok(Step::Failed(Failure::Held(reason), reply.intent)); + } + let request_id = if finality.revised { format!("{batch_id}-r{turn}-a") } else { format!("{batch_id}-a") }; + // A refusal, a fault and a silent mouth are told, never a failed step. Nothing is retried in the turn. + let saved = save_attachment(organs, config, &instance.0, request_id, ask); + outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("save_attachment {} {}: {}", ask.room, ask.event, saved.word()) }); + notices.push(organ_notice(unix_ms(organs.clock.now()), &saved.told())); } // The one origin of this turn: its memories and its brief record the same conversation. let origin = origin_of(config, batch, &inbox.origin); @@ -2131,7 +2182,7 @@ fn act(inbox: &PersonaInbox<'_, C, S>, batch } organs.trace.record(&record(outputs, "answered".into()))?; let opened = reply.remember.iter().zip(&memories).filter(|(remember, _)| matches!(remember.sort, MemorySort::Asked | MemorySort::Committed)).filter_map(|(_, memory)| pipeline_key(memory).ok()).collect(); - Ok(Step::Answered(started, reply.intent, opened, notice)) + Ok(Step::Answered(started, reply.intent, opened, notices)) } /// The seq an ack may pass because nothing in it is for her: the leading run of `pending` (oldest first) that is not addressed to any of @@ -2366,6 +2417,17 @@ fn open_in_bounds(ask: &OpenDirect, conversation: &Conversation, config: &Config && ask.text.len() <= OPEN_DIRECT_TEXT_MAX_BYTES } +/// Whether the save is hers to ask. With the flag off the key must be absent, null included; on, an ask names a room she may say in and +/// an event id's shape. +fn save_in_bounds(reply: &PersonaReply, rooms: &[String], config: &Config) -> bool { + match &reply.save_attachment { + None => true, + Some(_) if !config.fetch => false, + Some(None) => true, + Some(Some(ask)) => rooms.contains(&ask.room) && event_id_shape(&ask.event), + } +} + /// The reply as the one type it must be, with every room one of `rooms`, every person one of /// `people` and every settled or taken-on item one of the `open` items shown this turn. fn decode_reply(text: &str, rooms: &[String], seen: &Shown, conversation: &Conversation, config: &Config, addressing: &[Addressee]) -> Result { @@ -2391,6 +2453,9 @@ fn decode_reply(text: &str, rooms: &[String], seen: &Shown, conversation: &Conve if reply.say.iter().any(|say| !rooms.contains(&say.room) || say.text.trim().is_empty()) { return Err(Failure::RoomNotAllowed); } + if !save_in_bounds(&reply, rooms, config) { + return Err(Failure::OutOfBounds(SAVE_FAULT)); + } Ok(reply) } @@ -3238,6 +3303,95 @@ fn open_told(with: &str, outcome: Option) -> String { } } +/// What became of her save: a closed set, so nothing the mouth wrote reaches her. The three saved lines are their own arms. +#[derive(Clone, Debug, PartialEq, Eq)] +enum Saved { + /// Saved and verified; `receipt` Posted or Existing means the room shows it received, none means no receipt was posted. + Stored { path: String, receipt: Option }, + /// Saved, the event carried no hash: not verified, no receipt, never marked read. + Unverified { path: String }, + /// One of the mouth's twelve refusal words. + Refused(FetchOutcome), + /// The mouth refused the kind (UnknownKind): nothing was sent. + Unserved, + /// The mouth did not answer: it may or may not have fetched. + Unanswered, + /// No image store: decided before any request. + NoStore, + Fault(SaveFault), +} + +impl Saved { + /// The outcome as the trace spells it. + fn word(&self) -> String { + match self { + Self::Stored { receipt: Some(_), .. } => "Stored".into(), + Self::Stored { receipt: None, .. } => "StoredNoReceipt".into(), + Self::Unverified { .. } => FetchOutcome::StoredUnverified.name().into(), + Self::Refused(outcome) => outcome.name().into(), + Self::Unserved => "Unserved".into(), + Self::Unanswered => "Unanswered".into(), + Self::NoStore => "NoStore".into(), + Self::Fault(SaveFault::Load(skip)) => format!("Load {skip:?}"), + Self::Fault(fault) => format!("{fault:?}"), + } + } + + /// The one fixed line she is told. Only the saved lines carry the relative path. + fn told(&self) -> String { + use FetchOutcome as F; + match self { + Self::Stored { path, receipt: Some(_) } => format!("Your attachment is saved at {path} in your sandbox and checked against its hash. The room shows you received it."), + Self::Stored { path, receipt: None } => format!("Your attachment is saved at {path} in your sandbox and checked against its hash, but no receipt was posted, so it is not marked read."), + Self::Unverified { path } => format!("Your attachment is saved at {path} in your sandbox but not verified: the event carried no hash. No receipt was posted and it is not marked read."), + Self::Refused(outcome) => match outcome { + F::NotAMember => "Your attachment was not saved: you are not a member of that room.", + F::BridgedRoom => "Your attachment was not saved: that room is bridged from another network, and the mouth does not fetch from it.", + F::TooLarge => "Your attachment was not saved: the image is too large for the mouth to keep.", + F::TypeRefused => "Your attachment was not saved: the mouth keeps png, jpeg, gif and webp from unencrypted rooms only, and that file is not one it can keep.", + F::NoAttachment => "Your attachment was not saved: that event has no image attached.", + F::HashMismatch => "Your attachment was not saved: the file does not match the hash its event declared.", + F::RateLimited => "Your attachment was not saved: the mouth's hourly limit on saves for you is reached. Do not ask again this turn.", + F::Brake => "Your attachment was not saved: the brake is held.", + F::PersonaMismatch => "Your attachment was not saved: the mouth took the request for another Persona.", + F::UnknownPersona => "Your attachment was not saved: the mouth does not know you.", + F::FetchFailed => "Your attachment was not saved: the mouth could not fetch the file from the homeserver.", + F::StoreFailed => "Your attachment was not saved: the mouth could not store the file.", + F::Stored | F::StoredUnverified => unreachable!("a stored outcome is a saved line, not a refusal"), + } + .into(), + Self::Unserved => "Your attachment was not saved: the mouth does not serve attachments yet.".into(), + Self::Unanswered => "The mouth did not answer your request to keep an attachment, so it may or may not have gone through. Do not repeat it unless the file is missing.".into(), + Self::NoStore => format!("Your attachment was not saved: {}.", chat_image::Skip::NoStore.reason()), + Self::Fault(SaveFault::RepoTree) => "Your attachment was not saved: your sandbox, or its attachments folder, is a git work tree.".into(), + Self::Fault(SaveFault::OutsideSandbox) => "Your attachment was not saved: attachments in your sandbox is a link or not a folder.".into(), + Self::Fault(SaveFault::Exists) => "Your attachment was not saved: a different file already holds that name in attachments, and nothing was replaced.".into(), + Self::Fault(SaveFault::WriteFailed) => "Your attachment was not saved: the file could not be written into your sandbox.".into(), + Self::Fault(SaveFault::Load(skip)) => format!("Your attachment was not saved: {}.", skip.reason()), + } + } +} + +/// Asks the mouth for `ask`'s image and keeps what it verified. The store is checked before anything is sent; the organ posts no receipt +/// (the mouth does, as her, in the same request); a saved file's room id is the receipt's, which the mouth resolved from the alias. +fn save_attachment(organs: &Organs<'_>, config: &Config, persona: &str, request_id: String, ask: &SaveAttachment) -> Saved { + let Some(store) = config.image_store.as_deref() else { return Saved::NoStore }; + let request = ChatFetchRequest { request_id, persona: persona.into(), room: ask.room.clone(), event_id: ask.event.clone() }; + let receipt = match organs.chat.fetch(&request) { + Ok(receipt) => receipt, + Err(error) if error.downcast_ref::().is_some() => return Saved::Unserved, + Err(_) => return Saved::Unanswered, + }; + let (Some(room_id), Some(image)) = (&receipt.room_id, &receipt.image) else { return Saved::Refused(receipt.outcome) }; + match attachment::save(store, organs.sandbox, room_id, image) { + Ok(path) => { + let path = path.to_string_lossy().into_owned(); + if receipt.outcome == FetchOutcome::StoredUnverified { Saved::Unverified { path } } else { Saved::Stored { path, receipt: receipt.receipt } } + } + Err(fault) => Saved::Fault(fault), + } +} + /// The most the wear section may take of a first line. const WEAR_MAX_BYTES: usize = 1536; /// A campaign of hers whose newest admission in her mind is this old or older, and which holds an unreported spec, an open Soul diff --git a/crates/mind-body/src/persona/tests.rs b/crates/mind-body/src/persona/tests.rs index 897c131..a9dcaf7 100644 --- a/crates/mind-body/src/persona/tests.rs +++ b/crates/mind-body/src/persona/tests.rs @@ -84,6 +84,10 @@ struct FakeChat { open_outcome: Cell>, /// The mouth refuses the open kind outright (an older mouth): the open errors with the typed refusal. open_unserved: Cell, + /// The fetches asked of the mouth, in order. + fetches: RefCell>, + /// How the fake mouth answers a fetch. + fetch_answer: RefCell, /// Every line the fake homeserver holds, oldest first; none is a mouth with no tail. The reply carries them as the real mouth does: /// rooms she may speak in only, 20 a room and 60 in all, newest kept, oldest first (Bifrost `docs/bridge.md` "Room tail"). homeserver: RefCell>>, @@ -95,6 +99,16 @@ struct FakeChat { fail_pending: Cell, } +/// What a fake mouth does with a fetch. +#[derive(Clone)] +enum FetchAnswer { + Receipt(crate::chat_client::ChatFetchReceipt), + /// The kind is refused as unlisted. + Unserved, + /// No answer: the transport errors. + Down, +} + impl FakeChat { fn tail_reply(&self) -> Option> { let held = self.homeserver.borrow(); @@ -193,8 +207,14 @@ 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 fetch(&self, request: &crate::chat_client::ChatFetchRequest) -> Result { + self.log.borrow_mut().push("fetch".into()); + self.fetches.borrow_mut().push(request.clone()); + match self.fetch_answer.borrow().clone() { + FetchAnswer::Receipt(receipt) => Ok(crate::chat_client::ChatFetchReceipt { request_id: request.request_id.clone(), ..receipt }), + FetchAnswer::Unserved => Err(crate::chat_client::MouthRefused(Refusal::UnknownKind).into()), + FetchAnswer::Down => Err(anyhow!("CANARY-fetch-down {}", request.event_id)), + } } fn read(&self, read: &ChatRead) -> Result<()> { @@ -429,6 +449,7 @@ fn config() -> Config { model: "claude-test-model".into(), doctrine: vec![], image_store: None, + fetch: false, } } @@ -506,6 +527,8 @@ impl Rig { opens: RefCell::default(), open_outcome: Cell::new(Some(DirectOutcome::Opened)), open_unserved: Cell::new(false), + fetches: RefCell::default(), + fetch_answer: RefCell::new(FetchAnswer::Down), homeserver: RefCell::new(None), dms: RefCell::default(), gaps: RefCell::new(Vec::new()), @@ -945,7 +968,7 @@ fn the_decoder_holds_the_bounds_the_schema_states() { assert!(!ok(brief, repo, campaign), "{repo:?} {campaign:?} brief of {}", brief.chars().count()); } // The decoder's bounds are the schema's, read from one place. - let schema: serde_json::Value = serde_json::from_str(&reply_schema(&c.rooms, &[CAMPAIGN.to_string()], true)).unwrap(); + let schema: serde_json::Value = serde_json::from_str(&reply_schema(&c.rooms, &[CAMPAIGN.to_string()], true, false)).unwrap(); let ask = &schema["properties"]["start_self_run"]["anyOf"][1]["properties"]; assert_eq!(ask["repo"]["pattern"], REPO_PATTERN); assert_eq!(ask["campaign"]["enum"], json!([CAMPAIGN])); @@ -1642,7 +1665,7 @@ fn a_vote_or_urgency_outside_its_bounds_fails_the_turn_without_a_pulse_or_an_ack #[test] fn the_reply_schema_bounds_the_vote_and_the_urgency_to_the_pulse_kind() { - let schema: serde_json::Value = serde_json::from_str(&reply_schema(&["operator".to_string()], &[], true)).unwrap(); + let schema: serde_json::Value = serde_json::from_str(&reply_schema(&["operator".to_string()], &[], true, false)).unwrap(); assert_eq!(schema["required"], json!(["intent", "say", "open_direct", "start_self_run", "remember", "rule", "settle", "urgency_permille"])); assert_eq!(schema["properties"]["tempo_vote_beat_s"]["anyOf"][1], json!({ "type": "integer", "minimum": 60, "maximum": 86_400 })); let described = schema["properties"]["tempo_vote_beat_s"]["description"].as_str().expect("the vote key is described to the model"); @@ -1867,7 +1890,7 @@ fn the_persona_flags_come_from_the_environment_when_the_command_line_lacks_them( let (config, rest) = Config::from_args_and_env(&args("--instance eureka"), env, "usage").unwrap(); assert_eq!( config, - Config { rooms: vec!["swarm".into(), "usage".into()], operators: vec!["@op:chat".into(), "@op2:chat".into()], minds: vec!["@libby:chat".into(), "@aqua:chat".into()], own_sender: "@eureka:chat".into(), model: "claude-test-model".into(), doctrine: vec![], image_store: None } + Config { rooms: vec!["swarm".into(), "usage".into()], operators: vec!["@op:chat".into(), "@op2:chat".into()], minds: vec!["@libby:chat".into(), "@aqua:chat".into()], own_sender: "@eureka:chat".into(), model: "claude-test-model".into(), doctrine: vec![], image_store: None, fetch: false } ); assert_eq!(rest, ["--instance", "eureka"]); // A flag wins over its variable, one at a time. @@ -2160,7 +2183,7 @@ fn where_and_from_whom_she_learned_a_memory_comes_from_the_batch_and_no_model_fi let (_, memory) = memories(&wake).remove(0); assert_eq!((memory.origin, memory.sources.len()), (MemoryOrigin::Wake, 0)); // The schema has no such fields and the decoder refuses them. - let schema = reply_schema(&["swarm".to_string()], &[CAMPAIGN.to_string()], true); + let schema = reply_schema(&["swarm".to_string()], &[CAMPAIGN.to_string()], true, false); for field in ["origin", "heard_from", "sources"] { assert!(!schema.contains(field), "{field}"); } @@ -2191,7 +2214,7 @@ fn what_she_keeps_names_only_people_she_may_name_and_stays_in_bounds() { assert!(!ok(json!([{ "sort": "Idea", "text": "x", "about": [], "salience_permille": 1001 }]))); let without = reply(&[], json!([]), json!([]), json!(null)).replace("\"remember\":[],", ""); assert_eq!(decode_reply(&without, &rooms, &Shown { people: people.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]), Err(Failure::Undecodable), "remember is required"); - let schema: serde_json::Value = serde_json::from_str(&reply_schema(&rooms, &[CAMPAIGN.to_string()], true)).unwrap(); + let schema: serde_json::Value = serde_json::from_str(&reply_schema(&rooms, &[CAMPAIGN.to_string()], true, false)).unwrap(); let items = &schema["properties"]["remember"]["items"]["properties"]; assert_eq!(items["about"]["items"], json!({ "type": "string" }), "the people she may name change with every batch: the decoder holds them, the line lists them"); assert_eq!(items["sort"]["enum"], json!(["Asked", "Decided", "Committed", "Learned", "Did", "Idea", "Rapport"])); @@ -2271,7 +2294,7 @@ fn the_open_items_shown_are_what_the_decoder_lets_her_settle_or_take_on() { assert_eq!(decode_reply(&taking("yggdrasil:memory:b"), &rooms, &Shown { open: shown.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(FOR_ITEM_FAULT))); assert_eq!(decode_reply(&taking("new:0"), &rooms, &Shown { open: shown.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(FOR_ITEM_FAULT)), "new: names a memory this reply writes"); // The schema leaves the ids to the decoder and the line, and still offers the one-reply form. - let schema: serde_json::Value = serde_json::from_str(&reply_schema(&rooms, &[CAMPAIGN.to_string()], true)).unwrap(); + let schema: serde_json::Value = serde_json::from_str(&reply_schema(&rooms, &[CAMPAIGN.to_string()], true, false)).unwrap(); assert_eq!(schema["properties"]["settle"]["items"]["properties"]["item"], json!({ "type": "string" })); assert_eq!(schema["properties"]["start_self_run"]["anyOf"][1]["properties"]["for_item"]["anyOf"], json!([{ "type": "null" }, { "type": "string" }])); } @@ -3441,7 +3464,7 @@ fn a_first_line_with_messages_ends_on_the_work_question_after_them() { #[test] fn a_reply_states_its_intent_first() { - let schema: serde_json::Value = serde_json::from_str(&reply_schema(&["operator".to_string()], &[], true)).unwrap(); + let schema: serde_json::Value = serde_json::from_str(&reply_schema(&["operator".to_string()], &[], true, false)).unwrap(); assert_eq!(schema["required"][0], json!("intent")); assert!(schema["properties"]["intent"]["description"].as_str().unwrap().contains("whose decision you wait on") && INTENT_DESCRIPTION.ends_with("If you have already asked and nothing more can be done, say so and name the next thing.")); let rooms = ["operator".to_string()]; @@ -7914,14 +7937,14 @@ fn open_fault(reply: &str, people: &[String], conversation: &Conversation, confi #[test] fn open_direct_is_null_outside_a_public_session() { let (rooms, campaigns) = (vec!["swarm".to_string()], vec![CAMPAIGN.to_string()]); - let public: serde_json::Value = serde_json::from_str(&reply_schema(&rooms, &campaigns, true)).unwrap(); + let public: serde_json::Value = serde_json::from_str(&reply_schema(&rooms, &campaigns, true, false)).unwrap(); let ask = &public["properties"]["open_direct"]["anyOf"]; assert_eq!(ask[0], json!({ "type": "null" })); assert_eq!(ask[1]["required"], json!(["with", "text"])); assert_eq!(ask[1]["properties"]["with"], json!({ "type": "string" }), "the people grow during the session: not an enum"); assert_eq!(ask[1]["properties"]["text"]["minLength"], json!(1)); assert!(ask[1]["description"].as_str().unwrap().contains("until they write to you first"), "{}", ask[1]["description"]); - let direct: serde_json::Value = serde_json::from_str(&reply_schema(&rooms, &campaigns, false)).unwrap(); + let direct: serde_json::Value = serde_json::from_str(&reply_schema(&rooms, &campaigns, false, false)).unwrap(); assert_eq!(direct["properties"]["open_direct"], json!({ "type": "null" })); assert!(direct["required"].as_array().unwrap().contains(&json!("open_direct")), "required in both"); @@ -8113,7 +8136,7 @@ fn an_open_the_mouth_does_not_serve_is_told_as_not_sent() { #[test] fn the_daily_creation_policy_has_one_text_in_the_schema_and_the_notice() { - let schema = reply_schema(&["swarm".to_string()], &[], true); + let schema = reply_schema(&["swarm".to_string()], &[], true, false); let told = open_told("@ann:chat", Some(DirectOutcome::DailyLimit)); assert!(told.contains(OPEN_DAILY_POLICY), "{told}"); let schema: Value = serde_json::from_str(&schema).unwrap(); @@ -8889,3 +8912,276 @@ fn an_event_told_and_unanswered_that_the_mouth_renumbers_is_not_told_again() { let told: String = rig.model.requests.borrow().iter().map(|told| told.input.clone()).collect::>().join("\n"); assert_eq!(told.matches("text=\"first\"").count(), 1, "{told}"); } + +// ---- the attachment save: a reply field, behind the fetch flag, and the verified file in her sandbox ---- + +fn saving(room: &str, event: &str) -> String { + json!({ "intent": "Keep the picture", "say": [], "open_direct": null, "start_self_run": null, "remember": [], "rule": [], "settle": [], "urgency_permille": null, "save_attachment": { "room": room, "event": event } }).to_string() +} + +fn with_key(key: serde_json::Value) -> String { + let mut reply: serde_json::Value = serde_json::from_str(&says(&[])).unwrap(); + reply["save_attachment"] = key; + reply.to_string() +} + +fn on() -> Config { + Config { fetch: true, ..config() } +} + +fn save_fault(reply: &str, config: &Config) -> Option { + decode_reply(reply, &config.rooms, &Shown::default(), &Conversation::Public, config, &addressing_of(config, None, &own_addressee(config))).err() +} + +/// A rig whose store holds `PNG_BYTES` for `!swarm:chat` and whose mouth answers a fetch with `outcome`, the room id and that image. +fn fetching(outcome: crate::chat_client::FetchOutcome, receipt: Option, replies: &[String]) -> (Rig, tempfile::TempDir, ChatImage) { + let rig = public_rig(replies); + let store = tempfile::tempdir().unwrap(); + let image = stored_image(store.path(), "!swarm:chat", PNG_BYTES); + let stored = outcome.stored(); + *rig.chat.fetch_answer.borrow_mut() = FetchAnswer::Receipt(crate::chat_client::ChatFetchReceipt { request_id: String::new(), outcome, room_id: stored.then(|| "!swarm:chat".to_string()), image: stored.then(|| image.clone()), receipt }); + (rig, store, image) +} + +fn kept(rig: &Rig) -> Vec { + let mut names: Vec = std::fs::read_dir(rig.sandbox().join("attachments")).map(|dir| dir.map(|e| e.unwrap().file_name().into_string().unwrap()).collect()).unwrap_or_default(); + names.sort(); + names +} + +#[test] +fn with_the_flag_off_the_schema_has_no_save_attachment_and_with_it_on_the_property_is_optional() { + let (rooms, campaigns) = (vec!["swarm".to_string()], vec![CAMPAIGN.to_string()]); + let off: Value = serde_json::from_str(&reply_schema(&rooms, &campaigns, true, false)).unwrap(); + assert!(off["properties"].get("save_attachment").is_none()); + assert!(!off["required"].as_array().unwrap().iter().any(|name| name == "save_attachment")); + assert!(!off.to_string().contains("save_attachment")); + let on: Value = serde_json::from_str(&reply_schema(&rooms, &campaigns, true, true)).unwrap(); + assert!(on["properties"].get("save_attachment").is_some()); + assert!(!on["required"].as_array().unwrap().iter().any(|name| name == "save_attachment"), "like tempo_vote_beat_s: omission is no ask"); + assert_eq!(on["properties"]["save_attachment"]["anyOf"][1]["properties"]["room"]["enum"], json!(["swarm"]), "only rooms she may say in"); +} + +#[test] +fn a_flag_off_reply_naming_save_attachment_is_out_of_bounds_and_an_omitting_one_decodes_as_before() { + let fault = Some(Failure::OutOfBounds(SAVE_FAULT)); + assert_eq!(save_fault(&saving("swarm", "$e1"), &config()), fault); + assert_eq!(save_fault(&with_key(json!(null)), &config()), fault, "null names the key too"); + assert_eq!(save_fault(&says(&[]), &config()), None, "omitted decodes exactly as before"); + // And nothing leaves the organ for it. + let rig = public_rig(&[saving("swarm", "$e1"), saving("swarm", "$e1"), saving("swarm", "$e1")]); + *rig.chat.fetch_answer.borrow_mut() = FetchAnswer::Unserved; + let _ = rig.go(); + assert!(rig.chat.fetches.borrow().is_empty()); +} + +#[test] +fn with_the_flag_on_an_omitted_or_null_field_is_no_ask_and_an_object_is_bounds_checked() { + assert_eq!(save_fault(&says(&[]), &on()), None); + assert_eq!(save_fault(&with_key(json!(null)), &on()), None); + assert_eq!(save_fault(&saving("swarm", "$e1"), &on()), None); + let fault = Some(Failure::OutOfBounds(SAVE_FAULT)); + assert_eq!(save_fault(&saving("not-a-room", "$e1"), &on()), fault, "a room outside the reply rooms"); + assert_eq!(save_fault(&saving("!swarm:chat", "$e1"), &on()), fault, "a room id is not an alias she was shown"); + for event in ["", "$", "e1", "$e 1", "$\u{e9}", &format!("${}", "x".repeat(255))] { + assert_eq!(save_fault(&saving("swarm", event), &on()), fault, "{event:?}"); + } + assert_eq!(save_fault(&saving("swarm", &format!("${}", "x".repeat(254))), &on()), None); + assert_eq!(save_fault(&with_key(json!({ "room": "swarm", "event": "$e1", "path": "/etc" })), &on()), Some(Failure::Undecodable), "the reference is room and event only"); + assert_eq!(save_fault(&with_key(json!([{ "room": "swarm", "event": "$e1" }, { "room": "swarm", "event": "$e2" }])), &on()), Some(Failure::Undecodable), "one per reply: the field is an object"); +} + +#[test] +fn the_flag_is_exactly_one() { + let args = ["--rooms", "swarm", "--operator", "@op:chat", "--minds", "@eureka:chat", "--self-sender", "@eureka:chat"].map(String::from).to_vec(); + for (value, expected) in [(Some("1"), true), (Some("0"), false), (Some("true"), false), (Some(""), false), (Some(" 1"), false), (Some("11"), false), (None, false)] { + let lookup = move |name: &str| match name { + ENV_MODEL => Some("m".to_string()), + "BIFROST_CHAT_FETCH" => value.map(String::from), + _ => None, + }; + assert_eq!(Config::from_args_and_env(&args, lookup, "usage").unwrap().0.fetch, expected, "{value:?}"); + } +} + +#[test] +fn the_fetch_is_never_asked_on_the_poll_path() { + let (rig, store, _) = fetching(crate::chat_client::FetchOutcome::Stored, None, &[says(&[("swarm", "hello")])]); + assert_eq!(failure_of(rig.go_with(&Config { image_store: Some(store.path().to_path_buf()), ..on() })), None); + assert!(rig.chat.fetches.borrow().is_empty()); + assert!(!rig.log.borrow().iter().any(|step| step == "fetch")); +} + +#[test] +fn a_saved_image_from_an_alias_lands_by_hash_with_the_batch_request_id_and_one_line() { + use crate::chat_client::{FetchOutcome as F, ReceiptMode as R}; + let (rig, store, image) = fetching(F::Stored, Some(R::Posted), &[saving("swarm", "$pic"), says(&[])]); + let rig = { + let (deliveries, pushed) = (rig.chat.deliveries.clone(), Cell::new(false)); + *rig.model.during.borrow_mut() = Some(Box::new(move || { + if !pushed.replace(true) { + deliveries.borrow_mut().push(delivery(2, "swarm", "@ann:chat", "any news?")); + } + })); + rig + }; + assert_eq!(failure_of(rig.go_with(&Config { image_store: Some(store.path().to_path_buf()), ..on() })), None); + let asked = rig.chat.fetches.borrow().clone(); + assert_eq!(asked, [crate::chat_client::ChatFetchRequest { request_id: "s1-1-a".into(), persona: INSTANCE.into(), room: "swarm".into(), event_id: "$pic".into() }]); + let name = format!("{}.png", image.sha256); + assert_eq!(kept(&rig), [name.clone()]); + assert_eq!(std::fs::read(rig.sandbox().join("attachments").join(&name)).unwrap(), PNG_BYTES); + let line = input_of(&rig, 1); + assert!(line.contains(&format!("saved at attachments/{name}")) && line.contains("The room shows you received it"), "{line}"); + assert!(!line.contains("$pic") && !line.contains("!swarm:chat") && !line.contains("CANARY"), "no event id, room id or mouth text: {line}"); + let trace = rig.trace.0.borrow(); + assert!(trace[0].outputs.iter().any(|item| item.text == "save_attachment swarm $pic: Stored"), "{trace:?}"); +} + +#[test] +fn the_three_saved_lines_are_their_own_and_a_stored_with_no_receipt_is_not_a_failure() { + use crate::chat_client::{FetchOutcome as F, ReceiptMode as R}; + let mut told = Vec::new(); + for (outcome, receipt, word, says_not_read) in [(F::Stored, Some(R::Posted), "Stored", false), (F::Stored, Some(R::Existing), "Stored", false), (F::Stored, None, "StoredNoReceipt", true), (F::StoredUnverified, None, "StoredUnverified", true)] { + let (rig, store, image) = fetching(outcome, receipt, &[saving("swarm", "$pic"), says(&[])]); + let rig = { + let (deliveries, pushed) = (rig.chat.deliveries.clone(), Cell::new(false)); + *rig.model.during.borrow_mut() = Some(Box::new(move || { + if !pushed.replace(true) { + deliveries.borrow_mut().push(delivery(2, "swarm", "@ann:chat", "any news?")); + } + })); + rig + }; + let Outcome::Served { failure, .. } = rig.go_with(&Config { image_store: Some(store.path().to_path_buf()), ..on() }) else { panic!("served") }; + assert_eq!(failure, None, "{outcome:?} {receipt:?}"); + assert_eq!(kept(&rig), [format!("{}.png", image.sha256)], "the file is saved under the computed hash for every saved outcome"); + let line = input_of(&rig, 1); + assert_eq!(line.contains("not marked read"), says_not_read, "{outcome:?} {receipt:?}: {line}"); + assert_eq!(line.contains("not verified"), outcome == F::StoredUnverified, "{line}"); + assert!(rig.trace.0.borrow()[0].outputs.iter().any(|item| item.text == format!("save_attachment swarm $pic: {word}"))); + let tail = line.rsplit("notice role=organ").next().unwrap().to_string(); + told.push(tail.replace(&image.sha256, "H")); + } + assert_eq!(told[0], told[1], "Posted and Existing share one line"); + assert!(told[0] != told[2] && told[0] != told[3] && told[2] != told[3], "{told:?}"); +} + +#[test] +fn a_refusal_outcome_writes_no_file_and_is_told_in_its_own_line() { + use crate::chat_client::FetchOutcome as F; + let mut lines = BTreeSet::new(); + for outcome in [F::NotAMember, F::BridgedRoom, F::TooLarge, F::TypeRefused, F::NoAttachment, F::HashMismatch, F::RateLimited, F::Brake, F::PersonaMismatch, F::UnknownPersona, F::FetchFailed, F::StoreFailed] { + let (rig, store, _) = fetching(outcome, None, &[saving("swarm", "$pic"), says(&[])]); + let rig = { + let (deliveries, pushed) = (rig.chat.deliveries.clone(), Cell::new(false)); + *rig.model.during.borrow_mut() = Some(Box::new(move || { + if !pushed.replace(true) { + deliveries.borrow_mut().push(delivery(2, "swarm", "@ann:chat", "any news?")); + } + })); + rig + }; + assert_eq!(failure_of(rig.go_with(&Config { image_store: Some(store.path().to_path_buf()), ..on() })), None, "{outcome:?}: told, never a failed step"); + assert!(kept(&rig).is_empty(), "{outcome:?} saved a file"); + let line = input_of(&rig, 1); + let told = line.rsplit("notice role=organ").next().unwrap().to_string(); + assert!(told.contains("was not saved") && !told.contains("CANARY") && !told.contains("$pic"), "{outcome:?}: {told}"); + assert!(lines.insert(told), "{outcome:?} shares a line with another"); + assert!(rig.trace.0.borrow()[0].outputs.iter().any(|item| item.text == format!("save_attachment swarm $pic: {}", outcome.name()))); + } + assert_eq!(lines.len(), 12); +} + +#[test] +fn an_old_mouth_a_silent_mouth_and_no_store_are_told_and_not_retried() { + use crate::chat_client::{FetchOutcome as F, ReceiptMode as R}; + for (answer, told, word, sent) in [(FetchAnswer::Unserved, "does not serve attachments", "Unserved", 1), (FetchAnswer::Down, "did not answer", "Unanswered", 1)] { + let (rig, store, _) = fetching(F::Stored, Some(R::Posted), &[saving("swarm", "$pic"), says(&[])]); + *rig.chat.fetch_answer.borrow_mut() = answer; + let (deliveries, pushed) = (rig.chat.deliveries.clone(), Cell::new(false)); + *rig.model.during.borrow_mut() = Some(Box::new(move || { + if !pushed.replace(true) { + deliveries.borrow_mut().push(delivery(2, "swarm", "@ann:chat", "any news?")); + } + })); + assert_eq!(failure_of(rig.go_with(&Config { image_store: Some(store.path().to_path_buf()), ..on() })), None); + assert_eq!(rig.chat.fetches.borrow().len(), sent, "no retry in the turn"); + let line = input_of(&rig, 1); + assert!(line.contains(told) && !line.contains("CANARY"), "{line}"); + assert!(rig.trace.0.borrow()[0].outputs.iter().any(|item| item.text == format!("save_attachment swarm $pic: {word}"))); + assert!(kept(&rig).is_empty()); + } + // No image store: decided before anything is sent. + let (rig, _, _) = fetching(F::Stored, Some(R::Posted), &[saving("swarm", "$pic"), says(&[])]); + let (deliveries, pushed) = (rig.chat.deliveries.clone(), Cell::new(false)); + *rig.model.during.borrow_mut() = Some(Box::new(move || { + if !pushed.replace(true) { + deliveries.borrow_mut().push(delivery(2, "swarm", "@ann:chat", "any news?")); + } + })); + assert_eq!(failure_of(rig.go_with(&on())), None); + assert!(rig.chat.fetches.borrow().is_empty(), "nothing leaves the organ without a store"); + assert!(input_of(&rig, 1).contains("this Persona has no image store")); + assert!(rig.trace.0.borrow()[0].outputs.iter().any(|item| item.text == "save_attachment swarm $pic: NoStore")); +} + +#[test] +fn a_receipt_path_under_another_rooms_hash_or_a_missing_file_is_told_by_the_verifiers_reason_and_saves_nothing() { + use crate::chat_client::{FetchOutcome as F, ReceiptMode as R}; + let (rig, store, _) = fetching(F::Stored, Some(R::Posted), &[saving("swarm", "$pic"), says(&[])]); + let other = stored_image(store.path(), "!other:chat", PNG_BYTES); + let FetchAnswer::Receipt(receipt) = rig.chat.fetch_answer.borrow().clone() else { unreachable!() }; + *rig.chat.fetch_answer.borrow_mut() = FetchAnswer::Receipt(crate::chat_client::ChatFetchReceipt { image: Some(other), ..receipt }); + let (deliveries, pushed) = (rig.chat.deliveries.clone(), Cell::new(false)); + *rig.model.during.borrow_mut() = Some(Box::new(move || { + if !pushed.replace(true) { + deliveries.borrow_mut().push(delivery(2, "swarm", "@ann:chat", "any news?")); + } + })); + assert_eq!(failure_of(rig.go_with(&Config { image_store: Some(store.path().to_path_buf()), ..on() })), None); + assert!(kept(&rig).is_empty()); + assert!(input_of(&rig, 1).contains("the image reference leaves this room's store")); +} + +#[test] +fn the_gate_holds_a_fetch_as_it_holds_a_say() { + use crate::chat_client::{FetchOutcome as F, ReceiptMode as R}; + let (rig, store, _) = fetching(F::Stored, Some(R::Posted), &[saving("swarm", "$pic")]); + let control = rig.store(); + *rig.model.during.borrow_mut() = Some(Box::new(move || { + ControlWriter::open(&control).unwrap().set_brake(false, at(0), "op").unwrap(); + })); + let Outcome::Served { failure, .. } = rig.go_with(&Config { image_store: Some(store.path().to_path_buf()), ..on() }) else { panic!("served") }; + assert_eq!(failure, Some(Failure::Held(HeldReason::BrakeHeld))); + assert!(rig.chat.fetches.borrow().is_empty()); + assert!(kept(&rig).is_empty()); +} + +#[test] +fn both_notices_survive_a_reply_with_open_direct_and_save_attachment() { + use crate::chat_client::{FetchOutcome as F, ReceiptMode as R}; + let mut both: serde_json::Value = serde_json::from_str(&opening(&[], "@ann:chat", "Hello Ann")).unwrap(); + both["save_attachment"] = json!({ "room": "swarm", "event": "$pic" }); + let (rig, store, _) = fetching(F::Stored, Some(R::Posted), &[both.to_string(), says(&[])]); + let (deliveries, pushed) = (rig.chat.deliveries.clone(), Cell::new(false)); + *rig.model.during.borrow_mut() = Some(Box::new(move || { + if !pushed.replace(true) { + deliveries.borrow_mut().push(delivery(2, "swarm", "@ann:chat", "any news?")); + } + })); + assert_eq!(failure_of(rig.go_with(&Config { image_store: Some(store.path().to_path_buf()), ..on() })), None); + let log = rig.log.borrow().clone(); + let (open, fetch) = (log.iter().position(|step| step == "open"), log.iter().position(|step| step == "fetch")); + assert!(open.is_some() && fetch.is_some() && open < fetch, "one request each, the open first: {log:?}"); + assert_eq!(log.iter().filter(|step| *step == "open" || *step == "fetch").count(), 2); + let line = input_of(&rig, 1); + let (open, save) = (line.find("Your invitation to @ann:chat").expect("the open's line"), line.find("is saved at attachments/").expect("the save's line")); + assert!(open < save, "the open's line first: {line}"); + assert_eq!(line.matches("notice role=organ").count(), 2); + let trace = rig.trace.0.borrow(); + let words: Vec<_> = trace[0].outputs.iter().filter(|item| item.text.starts_with("open_direct") || item.text.starts_with("save_attachment")).map(|item| item.text.clone()).collect(); + assert_eq!(words, ["open_direct @ann:chat: Opened", "save_attachment swarm $pic: Stored"]); + assert_eq!(rig.chat.fetches.borrow()[0].request_id, "s1-1-a"); + assert_eq!(rig.chat.opens.borrow()[0].request_id, "s1-1-d"); +} diff --git a/crates/mind-body/tests/public_surface.txt b/crates/mind-body/tests/public_surface.txt index 1b6f104..2568fd7 100644 --- a/crates/mind-body/tests/public_surface.txt +++ b/crates/mind-body/tests/public_surface.txt @@ -236,7 +236,7 @@ mind_body::persona: const ENV_SELF_SENDER : & str mind_body::persona: const PERSONA_POLL_S : i64 mind_body::persona: const SANDBOX_DIR : & str mind_body::persona: fn carry (organs : & Organs , control : & impl ControlSource , swarm : & impl ControlSource , config : & Config) -> Result < Outcome > -mind_body::persona: fn reply_schema (rooms : & [String] , campaigns : & [String] , may_open : bool) -> String +mind_body::persona: fn reply_schema (rooms : & [String] , campaigns : & [String] , may_open : bool , fetch : bool) -> String mind_body::persona: impl Config mind_body::persona: impl Config: fn from_args (args : & [String] , usage : & str) -> Result < (Self , Vec < String >) > mind_body::persona: impl Config: fn from_args_and_env (args : & [String] , lookup : impl Fn (& str) -> Option < String > , usage : & str) -> Result < (Self , Vec < String >) > @@ -246,6 +246,7 @@ mind_body::persona: impl FileTrace: fn new (path : impl Into < PathBuf >) -> Sel mind_body::persona: impl HuginnPersona mind_body::persona: impl HuginnPersona: fn new (client : HuginnClient) -> Self mind_body::persona: impl PersonaState for HuginnPersona +mind_body::persona: impl Saved mind_body::persona: impl Sender mind_body::persona: impl Shown mind_body::persona: impl Trace for FileTrace @@ -259,14 +260,15 @@ mind_body::persona: impl< C : ControlSource , S : ControlSource > SessionInbox f mind_body::persona: pub trait PersonaState { fn instructions (& self) -> Result < String > ; } mind_body::persona: pub trait Trace { fn record (& self , trace : & TurnTrace) -> Result < () > ; } mind_body::persona: pub use model_connector :: PlanQuota ; -mind_body::persona: struct Config # [derive (Clone , Debug , PartialEq , Eq)] pub-fields[rooms: Vec < String >, operators: Vec < String >, minds: Vec < String >, own_sender: String, model: String, doctrine: Vec < PathBuf >, image_store: Option < PathBuf >] +mind_body::persona: struct Config # [derive (Clone , Debug , PartialEq , Eq)] pub-fields[rooms: Vec < String >, operators: Vec < String >, minds: Vec < String >, own_sender: String, model: String, doctrine: Vec < PathBuf >, image_store: Option < PathBuf >, fetch: bool] mind_body::persona: struct FileTrace pub-fields[] mind_body::persona: struct HuginnPersona pub-fields[] mind_body::persona: struct OpenDirect # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[with: String, text: String] mind_body::persona: struct Organs< 'a > pub-fields[mind: & 'a dyn MindPort, launcher: & 'a dyn Launcher, clock: & 'a dyn Clock, host: & 'a str, chat: & 'a dyn Chat, persona: & 'a dyn PersonaState, trace: & 'a dyn Trace, heartbeat: Option < & 'a dyn HeartbeatView >, lender: & 'a dyn Lender, spawner: & 'a dyn Spawner, run_cap_s: u64, sandbox: & 'a Path, scratch: & 'a Path] -mind_body::persona: struct PersonaReply # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[intent: String, say: Vec < Say >, open_direct: Option < OpenDirect >, start_self_run: Option < StartSelfRun >, remember: Vec < Remember >, rule: Vec < Rule >, settle: Vec < Settle >, tempo_vote_beat_s: Option < Option < u32 > >, urgency_permille: Option < u16 >] +mind_body::persona: struct PersonaReply # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[intent: String, say: Vec < Say >, open_direct: Option < OpenDirect >, start_self_run: Option < StartSelfRun >, remember: Vec < Remember >, rule: Vec < Rule >, settle: Vec < Settle >, tempo_vote_beat_s: Option < Option < u32 > >, urgency_permille: Option < u16 >, save_attachment: Option < Option < SaveAttachment > >] mind_body::persona: struct Remember # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[sort: MemorySort, text: String, about: Vec < String >, salience_permille: u16] mind_body::persona: struct Rule # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[by: String, words: String, repo: String, campaign: String, title: String, ruling: String] +mind_body::persona: struct SaveAttachment # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[room: String, event: String] mind_body::persona: struct Say # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[room: String, text: String] mind_body::persona: struct Settle # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[item: String, outcome: SettleOutcome, why: String] mind_body::persona: struct StartSelfRun # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[brief: String, repo: String, campaign: String, for_item: Option < String >] -- 2.47.3 From 835c1b47f62dbc1e6fc1c557ce9f60b95e957536 Mon Sep 17 00:00:00 2001 From: Eureka Date: Thu, 12 Nov 2026 01:59:38 +0000 Subject: [PATCH 4/4] Pin the saver's temp-create flags, the compare NOFOLLOW, the fetch UnknownKind wire path and the revised fetch id; document the receipt's meaning Tests only plus a cfg(test) gap seam between unlinkat and openat in attachment.rs and the module doc sentence on the file_received receipt (follow-up receipt-outlives-failed-save, accepted). Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/attachment.rs | 4 ++ crates/mind-body/src/attachment/tests.rs | 54 +++++++++++++++++++ crates/mind-body/src/persona/tests.rs | 69 ++++++++++++++++++++++++ crates/mind-body/tests/chat_rudp.rs | 28 +++++++++- 4 files changed, 154 insertions(+), 1 deletion(-) diff --git a/crates/mind-body/src/attachment.rs b/crates/mind-body/src/attachment.rs index 68b0a8b..8e1103d 100644 --- a/crates/mind-body/src/attachment.rs +++ b/crates/mind-body/src/attachment.rs @@ -8,6 +8,8 @@ //! operator's, not hers); every open below it is relative to a held descriptor and refuses a symlink, so a shell that swaps //! `attachments/` for a link between two calls finds the next call refusing it. The bytes go to a temporary name in that directory //! and are linked to the final name, which fails where a rename would replace her file; a crash leaves only the temporary. +//! +//! The file_received receipt means only that the mouth fetched and stored the file for her. It does not prove the file reached her sandbox. The receipt stays inside the mouth's fetch, so a failed save (Exists, WriteFailed, RepoTree, a Load skip) leaves the receipt standing; she is told the save failed in her own turn. use std::path::{Path, PathBuf}; @@ -82,6 +84,8 @@ fn write(sandbox: &Path, name: &str, bytes: &[u8]) -> Result<(), SaveFault> { Ok(()) | Err(Errno::NOENT) => {} Err(_) => return Err(SaveFault::WriteFailed), } + #[cfg(test)] + tests::between_unlink_and_create(); let file = fs::openat(&dir, temp.as_str(), OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::RUSR | Mode::WUSR) .map_err(|_| SaveFault::WriteFailed)?; let landed = (|| { diff --git a/crates/mind-body/src/attachment/tests.rs b/crates/mind-body/src/attachment/tests.rs index c32dac6..7d69f10 100644 --- a/crates/mind-body/src/attachment/tests.rs +++ b/crates/mind-body/src/attachment/tests.rs @@ -26,6 +26,19 @@ pub(super) fn crash_after(written: usize) -> bool { fire } +thread_local! { + static GAP: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; +} + +/// The seam between the unlink of the temporary's name and its create: where her shell could plant something at that name. +pub(super) fn between_unlink_and_create() { + GAP.with(|gap| { + if let Some(plant) = gap.borrow().as_ref() { + plant(); + } + }); +} + pub(super) fn crashed() -> bool { CRASHED.get() } @@ -305,3 +318,44 @@ fn the_saved_file_is_mode_0600_and_the_directory_0700() { assert_eq!(std::fs::metadata(world.sandbox.path().join(path)).unwrap().permissions().mode() & 0o7777, 0o600); assert_eq!(std::fs::metadata(world.attachments()).unwrap().permissions().mode() & 0o7777, 0o700); } + +#[test] +fn a_file_or_link_planted_at_the_temporary_name_after_its_unlink_is_refused_and_never_written_through() { + let world = World::new(); + let image = world.put(ROOM, PNG, "png"); + let victim = world.sandbox.path().join("victim"); + let part = world.attachments().join(format!(".{}.png.part", image.sha256)); + for plant_a_link in [false, true] { + std::fs::write(&victim, b"keep me").unwrap(); + let (victim_path, part_path) = (victim.clone(), part.clone()); + let plant: Box = Box::new(move || { + if plant_a_link { + symlink(&victim_path, &part_path).unwrap(); + } else { + // A regular file of her own: an create without O_EXCL would open it and truncate. + std::fs::write(&part_path, b"hers").unwrap(); + } + }); + GAP.with(|gap| *gap.borrow_mut() = Some(plant)); + let saved = world.save(&image); + GAP.with(|gap| *gap.borrow_mut() = None); + assert_eq!(saved, Err(SaveFault::WriteFailed), "link: {plant_a_link}"); + assert_eq!(std::fs::read(&victim).unwrap(), b"keep me", "nothing was written through the planted name (link: {plant_a_link})"); + if !plant_a_link { + assert_eq!(std::fs::read(&part).unwrap(), b"hers", "her file is not truncated"); + } + assert!(!world.attachments().join(format!("{}.png", image.sha256)).exists(), "no final name from a refused create"); + let _ = std::fs::remove_file(&part); + } +} + +#[test] +fn a_link_to_a_same_length_regular_file_at_the_final_name_is_exists_not_saved() { + let world = World::new(); + let image = world.put(ROOM, PNG, "png"); + let outside = world.sandbox.path().join("outside.bin"); + std::fs::write(&outside, PNG).unwrap(); + std::fs::create_dir(world.attachments()).unwrap(); + symlink(&outside, world.attachments().join(format!("{}.png", image.sha256))).unwrap(); + assert_eq!(world.save(&image), Err(SaveFault::Exists), "a link is not a file in her sandbox, whatever it points at"); +} diff --git a/crates/mind-body/src/persona/tests.rs b/crates/mind-body/src/persona/tests.rs index a9dcaf7..60f50f3 100644 --- a/crates/mind-body/src/persona/tests.rs +++ b/crates/mind-body/src/persona/tests.rs @@ -9185,3 +9185,72 @@ fn both_notices_survive_a_reply_with_open_direct_and_save_attachment() { assert_eq!(rig.chat.fetches.borrow()[0].request_id, "s1-1-a"); assert_eq!(rig.chat.opens.borrow()[0].request_id, "s1-1-d"); } + +#[test] +fn a_revised_reply_with_an_open_and_a_save_sends_them_under_different_request_ids() { + use crate::chat_client::{FetchOutcome as F, ReceiptMode as R}; + let store = tempfile::tempdir().unwrap(); + let both = |extra: &str| { + let mut reply: serde_json::Value = serde_json::from_str(&opening(&[("swarm", extra)], "@ann:chat", "Come and see")).unwrap(); + reply["save_attachment"] = json!({ "room": "swarm", "event": "$pic" }); + reply.to_string() + }; + let rig = Rig::new(vec![], &[]); + rig.clock.step.set(0); + let image = stored_image(store.path(), "!swarm:chat", PNG_BYTES); + *rig.chat.fetch_answer.borrow_mut() = FetchAnswer::Receipt(crate::chat_client::ChatFetchReceipt { request_id: String::new(), outcome: F::Stored, room_id: Some("!swarm:chat".into()), image: Some(image), receipt: Some(R::Posted) }); + let config = Config { image_store: Some(store.path().to_path_buf()), ..on() }; + let (ask, answer) = (delivery(1, "swarm", "@ann:chat", "who owns the stairs?"), delivery(2, "swarm", "@libby:chat", "the stairs are mine")); + arrives(&rig, &ask); + with_inbox(&rig, &config, vec![ask.clone()], |inbox| { + inbox.start_line().unwrap(); + inbox.echoed(); + arrives(&rig, &answer); + inbox.result(&reply_result(&both("I own the stairs")), true, false).unwrap(); + assert!(inbox.poll().unwrap().pending); + inbox.tell(false).unwrap(); + inbox.echoed(); + inbox.result(&reply_result(&both("then the cellar is mine")), true, false).unwrap(); + let (opens, fetches) = (rig.chat.opens.borrow().clone(), rig.chat.fetches.borrow().clone()); + assert_eq!((opens.len(), fetches.len()), (1, 1)); + assert_ne!(opens[0].request_id, fetches[0].request_id, "two documents never share an id"); + assert!(opens[0].request_id.contains("-r1-") && opens[0].request_id.ends_with("-d"), "{}", opens[0].request_id); + assert!(fetches[0].request_id.contains("-r1-") && fetches[0].request_id.ends_with("-a"), "{}", fetches[0].request_id); + }); +} + +#[test] +fn every_failed_save_is_told_to_her_in_her_own_next_turn_and_leaves_no_file() { + use crate::chat_client::{FetchOutcome as F, ReceiptMode as R}; + type Prepare = Box; + let cases: [(&str, Prepare, &str); 5] = [ + ("RepoTree", Box::new(|sandbox, _| std::fs::create_dir_all(sandbox.join(".git")).unwrap()), "git work tree"), + ("OutsideSandbox", Box::new(|sandbox, _| std::os::unix::fs::symlink("/", sandbox.join("attachments")).unwrap()), "link or not a folder"), + ("Exists", Box::new(|sandbox, sha| { + std::fs::create_dir_all(sandbox.join("attachments")).unwrap(); + std::fs::write(sandbox.join("attachments").join(format!("{sha}.png")), b"hers").unwrap(); + }), "a different file already holds that name"), + ("WriteFailed", Box::new(|sandbox, sha| std::fs::create_dir_all(sandbox.join("attachments").join(format!(".{sha}.png.part"))).unwrap()), "could not be written into your sandbox"), + ("Load", Box::new(|_, _| {}), "the stored image cannot be read"), + ]; + for (word, prepare, told) in cases { + let (rig, store, image) = fetching(F::Stored, Some(R::Posted), &[saving("swarm", "$pic"), says(&[])]); + let (deliveries, pushed) = (rig.chat.deliveries.clone(), Cell::new(false)); + *rig.model.during.borrow_mut() = Some(Box::new(move || { + if !pushed.replace(true) { + deliveries.borrow_mut().push(delivery(2, "swarm", "@ann:chat", "any news?")); + } + })); + std::fs::create_dir_all(rig.sandbox()).unwrap(); + prepare(&rig.sandbox(), &image.sha256); + if word == "Load" { + std::fs::remove_file(store.path().join(&image.path)).unwrap(); + } + assert_eq!(failure_of(rig.go_with(&Config { image_store: Some(store.path().to_path_buf()), ..on() })), None, "{word}: told, never a failed step"); + let line = input_of(&rig, 1); + assert!(line.contains("Your attachment was not saved") && line.contains(told), "{word}: {line}"); + assert!(!line.contains("The room shows you received it"), "{word}: {line}"); + assert!(rig.trace.0.borrow()[0].outputs.iter().any(|item| item.text.starts_with("save_attachment swarm $pic: ") && item.text.contains(word)), "{word}"); + assert!(!rig.sandbox().join("attachments").join(format!("{}.png", image.sha256)).exists() || word == "Exists", "{word}"); + } +} diff --git a/crates/mind-body/tests/chat_rudp.rs b/crates/mind-body/tests/chat_rudp.rs index 874eb33..f38dc38 100644 --- a/crates/mind-body/tests/chat_rudp.rs +++ b/crates/mind-body/tests/chat_rudp.rs @@ -132,7 +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)), + FETCH_REQUEST if for_eureka(&chat_client::decode::(&document.payload).unwrap().persona) => Some(if open.0 == REFUSAL { answer(open.0, open.1.unwrap_or(&document.record_key), open.2) } else { answer(FETCH_RECEIPT, &document.record_key, FETCH_POSTED_BYTES) }), _ => None, }; if let Some(reply) = reply { @@ -437,3 +437,29 @@ fn the_four_field_v3_crosses_real_rudp_with_its_gaps_and_the_three_field_one_wit let quiet = fetch(four_field(PENDING_TAIL_BYTES, &["#swarm:gamecult.org"])); assert!(!quiet.tail_gaps.contains(&"#operator:gamecult.org".to_string()), "a room the mouth did not name is not a gap, however few lines it has"); } + +#[test] +fn a_mouth_that_does_not_serve_the_fetch_kind_refuses_it_and_the_fetch_is_known_not_sent_at_once() { + let mouth = opening_with((REFUSAL, Some("eureka"), UNKNOWN_KIND_BYTES)); + let chat = client(&mouth, KEY, "eureka", Duration::from_secs(20)); + let request = chat_client::decode::(FETCH_REQUEST_BYTES).unwrap(); + let started = std::time::Instant::now(); + let error = chat.fetch(&request).unwrap_err(); + assert!(started.elapsed() < Duration::from_secs(5), "the refusal ends the wait: {:?}", started.elapsed()); + assert_eq!(error.downcast_ref::(), Some(&MouthRefused(Refusal::UnknownKind)), "{error}"); +} + +#[test] +fn a_fetch_refusal_of_another_reason_or_naming_another_persona_is_not_heard_as_not_sent() { + let request = chat_client::decode::(FETCH_REQUEST_BYTES).unwrap(); + // The fixture refusal is persona "ghost", UnknownPersona: a refusal for this Persona for a reason other than the kind. + let mouth = opening_with((REFUSAL, Some("ghost"), REFUSAL_BYTES)); + let error = client(&mouth, KEY, "ghost", Duration::from_secs(20)).fetch(&request).unwrap_err(); + assert!(error.downcast_ref::().is_none(), "only UnknownKind is a known not-sent: {error}"); + assert!(error.to_string().contains("UnknownPersona"), "{error}"); + // UnknownKind keyed by this persona but naming another. + let other = chat_client::encode(&ChatRefusal { persona: "someone-else".into(), refusal: Refusal::UnknownKind }).unwrap(); + let mouth = opening_with((REFUSAL, Some("eureka"), Box::leak(other.into_boxed_slice()))); + let error = client(&mouth, KEY, "eureka", Duration::from_secs(20)).fetch(&request).unwrap_err(); + assert!(error.downcast_ref::().is_none(), "the payload names another Persona: {error}"); +} -- 2.47.3