Persona saves a named attachment into her sandbox (BIFROST_CHAT_FETCH, off by default) #64

Merged
eureka merged 4 commits from eureka/persona-save-attachment into main 2026-11-12 02:07:13 +00:00
26 changed files with 1405 additions and 37 deletions

View file

@ -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

View file

@ -0,0 +1,154 @@
//! 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/<sha256>.<ext>` 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.
//!
//! 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};
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/<sha256>.<ext>`. An identical file already there counts as saved.
pub(crate) fn save(store: &Path, sandbox: &Path, room_id: &str, image: &ChatImage) -> Result<PathBuf, SaveFault> {
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),
}
#[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 = (|| {
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;

View file

@ -0,0 +1,361 @@
//! 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<Option<usize>> = const { Cell::new(None) };
static CRASHED: Cell<bool> = 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
}
thread_local! {
static GAP: std::cell::RefCell<Option<Box<dyn Fn()>>> = 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()
}
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<PathBuf, SaveFault> {
save(self.store.path(), self.sandbox.path(), ROOM, image)
}
fn attachments(&self) -> PathBuf {
self.sandbox.path().join("attachments")
}
fn files(&self) -> Vec<String> {
let mut names: Vec<String> = 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"<html>not an image</html>", "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<PathBuf, SaveFault> {
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<u8> = 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.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.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.
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 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 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();
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<dyn Fn()> = 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");
}

View file

@ -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://<ip>:<port>`, 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.

View file

@ -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.
@ -136,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()))
}
@ -688,6 +691,159 @@ impl TryFrom<RawOpenReceipt> 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<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.name())
}
}
impl<'de> Deserialize<'de> for FetchOutcome {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.name())
}
}
impl<'de> Deserialize<'de> for ReceiptMode {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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<String>,
pub image: Option<ChatImage>,
pub receipt: Option<ReceiptMode>,
}
#[derive(Deserialize)]
struct RawFetchReceipt {
request_id: String,
outcome: FetchOutcome,
room_id: Option<String>,
image: Option<ChatImage>,
receipt: Option<ReceiptMode>,
}
impl TryFrom<RawFetchReceipt> for ChatFetchReceipt {
type Error = &'static str;
fn try_from(raw: RawFetchReceipt) -> Result<Self, Self::Error> {
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<T: Serialize>(document: &T) -> Result<Vec<u8>> {
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<ChatSendReceipt>;
/// 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<ChatOpenDirectReceipt>;
/// 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<ChatFetchReceipt>;
/// 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.

View file

@ -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<ReceiptMode>) -> 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::<ChatFetchReceipt>(&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::<FetchOutcome>(&encode(&word).unwrap()).unwrap().name(), word);
}
// The organ-local words are never on the wire.
for local in ["NoStore", "Unserved", "Unanswered", "stored", ""] {
assert!(decode::<FetchOutcome>(&encode(&local).unwrap()).is_err(), "{local}");
}
assert!(decode::<ReceiptMode>(&encode(&"Skipped").unwrap()).is_err());
}
#[test]
fn every_outcome_is_spelled_as_the_mouth_spells_it() {
let all = [

View file

@ -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<Image, Skip> {
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<u8>, &'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

View file

@ -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<ChatFetchReceipt> {
// 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(|_| ())

View file

@ -101,6 +101,10 @@ impl Chat for FakeChat {
panic!("the heartbeat opens nothing")
}
fn fetch(&self, _: &crate::chat_client::ChatFetchRequest) -> Result<crate::chat_client::ChatFetchReceipt> {
panic!("the heartbeat fetches nothing")
}
fn ack(&self, _: u64) -> Result<()> {
panic!("the heartbeat never acks a delivery")
}

View file

@ -35,6 +35,7 @@
#![forbid(unsafe_code)]
mod address;
mod attachment;
pub mod chat_client;
mod chat_image;
pub mod chat_rudp;

View file

@ -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<PathBuf>,
/// 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<PathBuf>,
/// 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<u16>,
/// 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<Option<SaveAttachment>>,
}
#[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/<sha256>.<ext>, 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<String>, Option<String>),
/// 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<String>, Vec<String>),
/// 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::<Vec<_>>(), may_open);
let schema_of = |campaigns: &[(String, String)]| reply_schema(&rooms, &campaigns.iter().map(|(slug, _)| slug.clone()).collect::<Vec<_>>(), 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<C: ControlSource, S: ControlSource> 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<C: ControlSource, S: ControlSource>(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<C: ControlSource, S: ControlSource>(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<C: ControlSource, S: ControlSource>(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<PersonaReply, Failure> {
@ -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<DirectOutcome>) -> 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<ReceiptMode> },
/// 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::<MouthRefused>().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

View file

@ -84,6 +84,10 @@ struct FakeChat {
open_outcome: Cell<Option<DirectOutcome>>,
/// The mouth refuses the open kind outright (an older mouth): the open errors with the typed refusal.
open_unserved: Cell<bool>,
/// The fetches asked of the mouth, in order.
fetches: RefCell<Vec<crate::chat_client::ChatFetchRequest>>,
/// How the fake mouth answers a fetch.
fetch_answer: RefCell<FetchAnswer>,
/// 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<Option<Vec<TailLine>>>,
@ -95,6 +99,16 @@ struct FakeChat {
fail_pending: Cell<bool>,
}
/// 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<Vec<TailLine>> {
let held = self.homeserver.borrow();
@ -193,6 +207,16 @@ 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, request: &crate::chat_client::ChatFetchRequest) -> Result<crate::chat_client::ChatFetchReceipt> {
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<()> {
if self.fail_read.get() {
return Err(anyhow!("the mouth is down"));
@ -425,6 +449,7 @@ fn config() -> Config {
model: "claude-test-model".into(),
doctrine: vec![],
image_store: None,
fetch: false,
}
}
@ -502,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()),
@ -941,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]));
@ -1638,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");
@ -1863,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.
@ -2156,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}");
}
@ -2187,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"]));
@ -2267,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:<i> 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" }]));
}
@ -3437,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()];
@ -7910,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");
@ -8109,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();
@ -8885,3 +8912,345 @@ 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::<Vec<_>>().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<Failure> {
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<crate::chat_client::ReceiptMode>, 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<String> {
let mut names: Vec<String> = 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");
}
#[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<dyn Fn(&std::path::Path, &str)>;
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}");
}
}

View file

@ -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::<ChatPendingRequest>(&document.payload).unwrap().persona) => Some(answer(pending_kind, "eureka", pending_bytes)),
SEND_REQUEST if for_eureka(&chat_client::decode::<ChatSendRequest>(&document.payload).unwrap().persona) => Some(answer(SEND_RECEIPT, &document.record_key, SEND_RECEIPT_BYTES)),
OPEN_DIRECT_REQUEST if for_eureka(&chat_client::decode::<ChatOpenDirectRequest>(&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::<ChatFetchRequest>(&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 {
@ -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::<ChatFetchRequest>(FETCH_REQUEST_BYTES).unwrap();
let receipt = chat.fetch(&request).unwrap();
assert_eq!(receipt, chat_client::decode::<ChatFetchReceipt>(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)
@ -417,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::<ChatFetchRequest>(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::<MouthRefused>(), 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::<ChatFetchRequest>(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::<MouthRefused>().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::<MouthRefused>().is_none(), "the payload names another Persona: {error}");
}

View file

@ -0,0 +1 @@
95a67265712d6631ab42726964676564526f6f6dc0c0c0

View file

@ -0,0 +1 @@
•¦req-f1«BridgedRoomÀÀÀ

View file

@ -0,0 +1 @@
95a67265712d6631a653746f726564b1216162633a67616d6563756c742e6f726794d985636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463642f616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261622e706e67a9696d6167652f706e67cd0800d94061626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162a84578697374696e67

View file

@ -0,0 +1 @@
95a67265712d6631a653746f726564b1216162633a67616d6563756c742e6f726794d985636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463642f616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261622e706e67a9696d6167652f706e67cd0800d94061626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162c0

View file

@ -0,0 +1 @@
95a67265712d6631a653746f726564b1216162633a67616d6563756c742e6f726794d985636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463642f616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261622e706e67a9696d6167652f706e67cd0800d94061626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162a6506f73746564

View file

@ -0,0 +1 @@
95a67265712d6631b053746f726564556e7665726966696564b1216162633a67616d6563756c742e6f726794d985636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463642f616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261622e706e67a9696d6167652f706e67cd0800d94061626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162c0

View file

@ -0,0 +1 @@
94a67265712d6631a6657572656b61b323737761726d3a67616d6563756c742e6f7267a62466696c6531

View file

@ -0,0 +1 @@
”¦req-f1¦eureka³#swarm:gamecult.org¦$file1

View file

@ -17,10 +17,14 @@ mind_body::address: struct Name # [derive (Clone , Debug , PartialEq , Eq , serd
mind_body::chat_client: # [derive (Clone , Copy , Debug , Default , PartialEq , Eq)] pub enum Form { # [default] Message , Notice , Edit , }
mind_body::chat_client: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Basis { Direct , Tag , Reply , Room , }
mind_body::chat_client: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum DirectOutcome { Opened , Existing , Brake , PersonaMismatch , UnknownPersona , EmptyText , ForeignUser , NotAPerson , NoSuchUser , InvitePending , Declined , DailyLimit , SendFailed , }
mind_body::chat_client: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum FetchOutcome { Stored , StoredUnverified , NotAMember , BridgedRoom , TooLarge , TypeRefused , NoAttachment , HashMismatch , RateLimited , Brake , PersonaMismatch , UnknownPersona , FetchFailed , StoreFailed , }
mind_body::chat_client: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum ReceiptMode { Posted , Existing , }
mind_body::chat_client: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Refusal { Brake , PersonaMismatch , UnknownRoom , EmptyText , SendFailed , UnknownPersona , RoomMoved , UnknownKind , }
mind_body::chat_client: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum ReplyViolation { NotATriple , BadEventId , BadSenderId , ExcerptNotText , ExcerptTooLong , }
mind_body::chat_client: # [derive (Clone , Debug , Default , PartialEq , Eq)] pub enum ReplyLink { # [default] Absent , Quote (ReplyTo) , Dropped (ReplyViolation) , }
mind_body::chat_client: const ACK : & str
mind_body::chat_client: const FETCH_RECEIPT : & str
mind_body::chat_client: const FETCH_REQUEST : & str
mind_body::chat_client: const OPEN_DIRECT_RECEIPT : & str
mind_body::chat_client: const OPEN_DIRECT_REQUEST : & str
mind_body::chat_client: const PENDING : & str
@ -42,8 +46,13 @@ mind_body::chat_client: impl Basis
mind_body::chat_client: impl Basis: fn name (self) -> & '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]
@ -220,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 >) >
@ -230,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
@ -243,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 >]