diff --git a/crates/mind-body/Cargo.toml b/crates/mind-body/Cargo.toml index e21212b..6ea906b 100644 --- a/crates/mind-body/Cargo.toml +++ b/crates/mind-body/Cargo.toml @@ -54,8 +54,9 @@ uuid = { version = "1", default-features = false, features = ["v5"] } libc = "0.2" # The lease socket: SCM_RIGHTS receive with MSG_CMSG_CLOEXEC and the descriptor # duplicates, as safe calls (the library forbids unsafe code). Already in the -# graph through tempfile; this adds its `net` feature only. -rustix = { version = "1", default-features = false, features = ["std", "net"] } +# graph through tempfile; this adds its `net` feature, and `fs` for the attachment +# saver's descriptor-relative writes (openat, linkat, unlinkat). +rustix = { version = "1", default-features = false, features = ["std", "net", "fs"] } # `syn`, `quote` and `proc-macro2` read the library's own source for # tests/public_surface.rs, which pins its public surface. All three are already diff --git a/crates/mind-body/src/attachment.rs b/crates/mind-body/src/attachment.rs new file mode 100644 index 0000000..68b0a8b --- /dev/null +++ b/crates/mind-body/src/attachment.rs @@ -0,0 +1,150 @@ +//! The attachment saver: a verified chat image becomes one file in the Persona's sandbox. +//! +//! `save` re-verifies what the mouth stored through `chat_image::load_bytes` (the one verifier; nothing here sniffs, hashes or +//! sizes a file itself) and writes `attachments/.` below the sandbox root. The name derives from the verified hash +//! and one of four extensions; no component comes from the event, the reply, the mouth or the stored path. +//! +//! Containment is by directory descriptor, not by a path check. The sandbox root is opened once by its configured path (the +//! operator's, not hers); every open below it is relative to a held descriptor and refuses a symlink, so a shell that swaps +//! `attachments/` for a link between two calls finds the next call refusing it. The bytes go to a temporary name in that directory +//! and are linked to the final name, which fails where a rename would replace her file; a crash leaves only the temporary. + +use std::path::{Path, PathBuf}; + +use crate::chat_client::ChatImage; +use crate::chat_image::{self, Skip}; + +/// Why a verified image was not saved. `Load` carries the verifier's own refusal, told by its own fixed reason. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SaveFault { + /// A git work tree at the sandbox root or in `attachments/`: a Matrix file does not go into one. + RepoTree, + /// The sandbox root or `attachments/` is a link or not a directory. + OutsideSandbox, + /// A different file already holds the name. + Exists, + WriteFailed, + Load(Skip), +} + +/// The extension of a verified media type: one of four, never from outside. +fn extension(media_type: &str) -> Option<&'static str> { + match media_type { + "image/png" => Some("png"), + "image/jpeg" => Some("jpg"), + "image/gif" => Some("gif"), + "image/webp" => Some("webp"), + _ => None, + } +} + +/// Saves `image`, which the mouth stored for `room_id` under `store`, into `sandbox`. The path is relative to the sandbox: +/// `attachments/.`. An identical file already there counts as saved. +pub(crate) fn save(store: &Path, sandbox: &Path, room_id: &str, image: &ChatImage) -> Result { + let (bytes, media_type) = chat_image::load_bytes(store, room_id, image).map_err(SaveFault::Load)?; + let extension = extension(media_type).ok_or(SaveFault::Load(Skip::NotAnImage))?; + let name = format!("{}.{extension}", image.sha256); + write(sandbox, &name, &bytes)?; + Ok(Path::new("attachments").join(name)) +} + +#[cfg(windows)] +fn write(_sandbox: &Path, _name: &str, _bytes: &[u8]) -> Result<(), SaveFault> { + // The organ runs under bubblewrap on Linux; there is no descriptor-relative write here. + Err(SaveFault::WriteFailed) +} + +#[cfg(unix)] +fn write(sandbox: &Path, name: &str, bytes: &[u8]) -> Result<(), SaveFault> { + use rustix::fs::{self, AtFlags, Mode, OFlags}; + use rustix::io::Errno; + + let dir_flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC; + let refused = |errno: Errno| if matches!(errno, Errno::LOOP | Errno::NOTDIR) { SaveFault::OutsideSandbox } else { SaveFault::WriteFailed }; + let work_tree = |dir: &rustix::fd::OwnedFd| match fs::statat(dir, ".git", AtFlags::SYMLINK_NOFOLLOW) { + Ok(_) => Err(SaveFault::RepoTree), + Err(Errno::NOENT) => Ok(()), + Err(_) => Err(SaveFault::WriteFailed), + }; + + let root = fs::open(sandbox, dir_flags, Mode::empty()).map_err(refused)?; + work_tree(&root)?; + match fs::mkdirat(&root, "attachments", Mode::RWXU) { + Ok(()) | Err(Errno::EXIST) => {} + Err(_) => return Err(SaveFault::WriteFailed), + } + let dir = fs::openat(&root, "attachments", dir_flags, Mode::empty()).map_err(refused)?; + work_tree(&dir)?; + + let temp = format!(".{name}.part"); + // A crashed save's leavings, even a FIFO or a link, are removed without being followed. + match fs::unlinkat(&dir, temp.as_str(), AtFlags::empty()) { + Ok(()) | Err(Errno::NOENT) => {} + Err(_) => return Err(SaveFault::WriteFailed), + } + let file = fs::openat(&dir, temp.as_str(), OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::RUSR | Mode::WUSR) + .map_err(|_| SaveFault::WriteFailed)?; + let landed = (|| { + let mut rest = bytes; + while !rest.is_empty() { + #[cfg(test)] + if tests::crash_after(bytes.len() - rest.len()) { + return Err(Errno::IO); + } + let chunk = &rest[..rest.len().min(8192)]; + match rustix::io::write(&file, chunk) { + Ok(0) => return Err(Errno::IO), + Ok(n) => rest = &rest[n..], + Err(Errno::INTR) => {} + Err(e) => return Err(e), + } + } + fs::fsync(&file)?; + fs::linkat(&dir, temp.as_str(), &dir, name, AtFlags::empty()) + })(); + match landed { + Ok(()) => { + let _ = fs::unlinkat(&dir, temp.as_str(), AtFlags::empty()); + Ok(()) + } + Err(Errno::EXIST) => { + let _ = fs::unlinkat(&dir, temp.as_str(), AtFlags::empty()); + same_file(&dir, name, bytes) + } + // A simulated crash leaves the temporary as a real one would. + #[cfg(test)] + Err(Errno::IO) if tests::crashed() => Err(SaveFault::WriteFailed), + Err(_) => { + let _ = fs::unlinkat(&dir, temp.as_str(), AtFlags::empty()); + Err(SaveFault::WriteFailed) + } + } +} + +/// Whether the file at `name` holds exactly `bytes`. It is a name her shell controls, so it is opened without following a link +/// and without waiting (a FIFO opens), must be a regular file of exactly the image's length, and is read for at most that length +/// plus one byte. Anything else is `Exists` with nothing read. +#[cfg(unix)] +fn same_file(dir: &rustix::fd::OwnedFd, name: &str, bytes: &[u8]) -> Result<(), SaveFault> { + use rustix::fs::{self, FileType, Mode, OFlags}; + + let file = fs::openat(dir, name, OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC, Mode::empty()).map_err(|_| SaveFault::Exists)?; + let stat = fs::fstat(&file).map_err(|_| SaveFault::Exists)?; + if FileType::from_raw_mode(stat.st_mode) != FileType::RegularFile || u64::try_from(stat.st_size) != Ok(bytes.len() as u64) { + return Err(SaveFault::Exists); + } + let mut held = vec![0u8; bytes.len() + 1]; + let mut filled = 0; + while filled < held.len() { + match rustix::io::read(&file, &mut held[filled..]) { + Ok(0) => break, + Ok(n) => filled += n, + Err(rustix::io::Errno::INTR) => {} + Err(_) => return Err(SaveFault::Exists), + } + } + if held[..filled] == *bytes { Ok(()) } else { Err(SaveFault::Exists) } +} + +#[cfg(test)] +mod tests; diff --git a/crates/mind-body/src/attachment/tests.rs b/crates/mind-body/src/attachment/tests.rs new file mode 100644 index 0000000..6f72e9e --- /dev/null +++ b/crates/mind-body/src/attachment/tests.rs @@ -0,0 +1,324 @@ +//! The saver against a real store and a real sandbox on disk: each guard against the one input it exists for. + +use std::cell::Cell; +use std::os::unix::fs::{PermissionsExt, symlink}; +use std::time::Duration; + +use sha2::{Digest, Sha256}; + +use super::*; +use crate::chat_image::{hex, room_dir}; + +const ROOM: &str = "!room:gamecult.test"; +const PNG: &[u8] = b"\x89PNG\r\n\x1a\nnot a real picture, only its magic bytes and a tail"; + +thread_local! { + static CRASH_AT: Cell> = const { Cell::new(None) }; + static CRASHED: Cell = const { Cell::new(false) }; +} + +/// The write loop's seam: true once `written` bytes have gone out and a crash is armed on this thread. +pub(super) fn crash_after(written: usize) -> bool { + let fire = CRASH_AT.get().is_some_and(|at| written >= at); + if fire { + CRASHED.set(true); + } + fire +} + +pub(super) fn crashed() -> bool { + CRASHED.get() +} + +struct World { + store: tempfile::TempDir, + sandbox: tempfile::TempDir, +} + +impl World { + fn new() -> Self { + Self { store: tempfile::tempdir().unwrap(), sandbox: tempfile::tempdir().unwrap() } + } + + /// Stores `bytes` the way the mouth does under `room`, named by `file_ext`, and describes them honestly. + fn put(&self, room: &str, bytes: &[u8], file_ext: &str) -> ChatImage { + let sha256 = hex(&Sha256::digest(bytes)); + let room = room_dir(room); + std::fs::create_dir_all(self.store.path().join(&room)).unwrap(); + let name = if file_ext.is_empty() { sha256.clone() } else { format!("{sha256}.{file_ext}") }; + std::fs::write(self.store.path().join(&room).join(&name), bytes).unwrap(); + let mime = match bytes { + [0xff, 0xd8, ..] => "image/jpeg", + [b'G', b'I', b'F', ..] => "image/gif", + [b'R', b'I', b'F', b'F', ..] => "image/webp", + _ => "image/png", + }; + ChatImage { path: format!("{room}/{name}"), mime: mime.into(), size: bytes.len() as u64, sha256 } + } + + fn save(&self, image: &ChatImage) -> Result { + save(self.store.path(), self.sandbox.path(), ROOM, image) + } + + fn attachments(&self) -> PathBuf { + self.sandbox.path().join("attachments") + } + + fn files(&self) -> Vec { + let mut names: Vec = std::fs::read_dir(self.attachments()).map(|dir| dir.map(|e| e.unwrap().file_name().into_string().unwrap()).collect()).unwrap_or_default(); + names.sort(); + names + } +} + +#[test] +fn the_saved_name_is_the_hash_and_one_of_four_extensions() { + let world = World::new(); + for (bytes, ext) in [(PNG, "png"), (&b"\xff\xd8\xff\xe0 jpeg tail"[..], "jpg"), (&b"GIF89a gif tail"[..], "gif"), (&b"RIFF\x10\0\0\0WEBPVP8 tail"[..], "webp")] { + // The stored file's own extension is the mouth's and is not used. + let image = world.put(ROOM, bytes, "exe"); + let path = world.save(&image).unwrap(); + assert_eq!(path, Path::new("attachments").join(format!("{}.{ext}", image.sha256))); + assert_eq!(std::fs::read(world.sandbox.path().join(&path)).unwrap(), bytes); + } + assert_eq!(world.files().len(), 4, "{:?}", world.files()); +} + +#[test] +fn a_receipt_path_that_leaves_the_store_or_names_no_stored_file_is_refused_and_writes_nothing() { + let world = World::new(); + let good = world.put(ROOM, PNG, "png"); + for path in [format!("../{}", good.path), format!("/{}", good.path), good.path.replace('/', "/../"), format!("{}/x", good.path), String::from("x")] { + let image = ChatImage { path, ..good.clone() }; + assert!(matches!(world.save(&image), Err(SaveFault::Load(Skip::BadReference | Skip::OutsideStore))), "{image:?}"); + } + assert!(!world.attachments().exists(), "a refused reference leaves not even the directory"); +} + +#[test] +fn the_saver_reverifies_what_the_mouth_stored() { + let world = World::new(); + let image = world.put(ROOM, PNG, "png"); + let stored = world.store.path().join(&image.path); + // Wrong bytes under the right name. + std::fs::write(&stored, b"\x89PNG\r\n\x1a\nsome other picture, same magic bytes, same-ish size.....").unwrap(); + assert!(matches!(world.save(&image), Err(SaveFault::Load(Skip::SizeMismatch | Skip::HashMismatch)))); + // Not an image at all, honestly hashed. + let html = world.put(ROOM, b"not an image", "png"); + assert_eq!(world.save(&html), Err(SaveFault::Load(Skip::NotAnImage))); + // A declared type that is not the sniffed one. + let image = ChatImage { mime: "image/gif".into(), ..world.put(ROOM, PNG, "png") }; + assert_eq!(world.save(&image), Err(SaveFault::Load(Skip::MimeMismatch))); + // Evicted by the mouth. + let image = world.put(ROOM, PNG, "png"); + std::fs::remove_file(world.store.path().join(&image.path)).unwrap(); + assert_eq!(world.save(&image), Err(SaveFault::Load(Skip::Missing))); + assert!(world.files().is_empty()); +} + +#[test] +fn a_receipt_path_under_another_rooms_hash_is_not_copied() { + let world = World::new(); + let image = world.put("!other:gamecult.test", PNG, "png"); + // The room id the mouth resolved is ROOM, but the path sits under another room's hash. + assert_eq!(world.save(&image), Err(SaveFault::Load(Skip::OutsideStore))); + // The alias is not a room id: loading with it reaches no store. + let own = world.put(ROOM, PNG, "png"); + assert_eq!(save(world.store.path(), world.sandbox.path(), "#room:gamecult.test", &own), Err(SaveFault::Load(Skip::OutsideStore))); + assert!(world.files().is_empty()); +} + +#[test] +fn every_skip_the_verifier_returns_is_a_save_fault_load() { + let world = World::new(); + let big = world.put(ROOM, PNG, "png"); + assert_eq!(world.save(&ChatImage { size: 6 * 1024 * 1024, ..big.clone() }), Err(SaveFault::Load(Skip::TooLarge))); + assert_eq!(world.save(&ChatImage { size: 3, ..big }), Err(SaveFault::Load(Skip::SizeMismatch))); +} + +#[test] +fn the_saver_refuses_a_git_work_tree() { + let at_root = World::new(); + std::fs::create_dir(at_root.sandbox.path().join(".git")).unwrap(); + assert_eq!(at_root.save(&at_root.put(ROOM, PNG, "png")), Err(SaveFault::RepoTree)); + assert!(!at_root.attachments().exists()); + + let inside = World::new(); + std::fs::create_dir_all(inside.attachments().join(".git")).unwrap(); + assert_eq!(inside.save(&inside.put(ROOM, PNG, "png")), Err(SaveFault::RepoTree)); + assert_eq!(inside.files(), [".git"]); + + // A .git file (a worktree or submodule) is a work tree too, and a link named .git counts without being followed. + let linked = World::new(); + symlink("/nonexistent", linked.sandbox.path().join(".git")).unwrap(); + assert_eq!(linked.save(&linked.put(ROOM, PNG, "png")), Err(SaveFault::RepoTree)); +} + +#[test] +fn a_symlinked_attachments_directory_or_sandbox_is_outside() { + let world = World::new(); + let outside = tempfile::tempdir().unwrap(); + symlink(outside.path(), world.attachments()).unwrap(); + assert_eq!(world.save(&world.put(ROOM, PNG, "png")), Err(SaveFault::OutsideSandbox)); + assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0); + + let file = World::new(); + std::fs::write(file.attachments(), b"a file where the directory goes").unwrap(); + assert_eq!(file.save(&file.put(ROOM, PNG, "png")), Err(SaveFault::OutsideSandbox)); + + let linked_root = World::new(); + let link = linked_root.sandbox.path().join("link"); + symlink(outside.path(), &link).unwrap(); + let image = linked_root.put(ROOM, PNG, "png"); + assert_eq!(save(linked_root.store.path(), &link, ROOM, &image), Err(SaveFault::OutsideSandbox)); + assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0); +} + +#[test] +fn the_saver_cannot_be_walked_out_by_a_swapping_sandbox() { + let world = World::new(); + let outside = tempfile::tempdir().unwrap(); + let image = world.put(ROOM, PNG, "png"); + let attachments = world.attachments(); + let parked = world.sandbox.path().join("parked"); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let swapper = { + let (stop, outside) = (stop.clone(), outside.path().to_path_buf()); + let (attachments, parked) = (attachments.clone(), parked.clone()); + std::thread::spawn(move || { + while !stop.load(std::sync::atomic::Ordering::SeqCst) { + // Between a real directory and a link to the outside, as her shell could. + let _ = std::fs::rename(&attachments, &parked); + let _ = symlink(&outside, &attachments); + let _ = std::fs::remove_file(&attachments); + let _ = std::fs::rename(&parked, &attachments); + } + }) + }; + for _ in 0..200 { + match world.save(&image) { + Ok(_) | Err(SaveFault::OutsideSandbox | SaveFault::WriteFailed | SaveFault::Exists) => {} + Err(other) => panic!("{other:?}"), + } + } + stop.store(true, std::sync::atomic::Ordering::SeqCst); + swapper.join().unwrap(); + assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0, "nothing reached the outside directory"); +} + +#[test] +fn an_identical_existing_file_counts_as_saved_and_leaves_no_part() { + let world = World::new(); + let image = world.put(ROOM, PNG, "png"); + let path = world.save(&image).unwrap(); + assert_eq!(world.save(&image), Ok(path.clone()), "a replayed request is not an error"); + assert_eq!(world.files(), [path.file_name().unwrap().to_str().unwrap()]); + + // Same length, other bytes under the name: Exists, and the file is left as it was. + let held = world.sandbox.path().join(&path); + let mut other = PNG.to_vec(); + *other.last_mut().unwrap() ^= 1; + std::fs::write(&held, &other).unwrap(); + assert_eq!(world.save(&image), Err(SaveFault::Exists)); + assert_eq!(std::fs::read(&held).unwrap(), other); + // Another length. + std::fs::write(&held, b"short").unwrap(); + assert_eq!(world.save(&image), Err(SaveFault::Exists)); + assert_eq!(world.files().len(), 1, "no .part left behind"); +} + +/// Runs `save` on a thread and fails the test if it does not return: a FIFO opened for reading would wait forever. +fn save_within(world: &World, image: &ChatImage) -> Result { + let (store, sandbox, image) = (world.store.path().to_path_buf(), world.sandbox.path().to_path_buf(), image.clone()); + let (send, receive) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = send.send(save(&store, &sandbox, ROOM, &image)); + }); + receive.recv_timeout(Duration::from_secs(10)).expect("the saver hung") +} + +#[test] +fn a_fifo_or_odd_file_at_the_final_name_does_not_block_the_organ() { + use rustix::fs::{CWD, FileType, Mode, mknodat}; + let world = World::new(); + let image = world.put(ROOM, PNG, "png"); + let held = world.attachments().join(format!("{}.png", image.sha256)); + std::fs::create_dir(world.attachments()).unwrap(); + + mknodat(CWD, &held, FileType::Fifo, Mode::RUSR | Mode::WUSR, 0).unwrap(); + assert_eq!(save_within(&world, &image), Err(SaveFault::Exists)); + std::fs::remove_file(&held).unwrap(); + + symlink("/dev/zero", &held).unwrap(); + assert_eq!(save_within(&world, &image), Err(SaveFault::Exists)); + std::fs::remove_file(&held).unwrap(); + + std::fs::create_dir(&held).unwrap(); + assert_eq!(save_within(&world, &image), Err(SaveFault::Exists)); + std::fs::remove_dir(&held).unwrap(); + + // A regular file of another length is not read at all; of the same length, only that many bytes plus one. + std::fs::write(&held, vec![0u8; 5 * 1024 * 1024]).unwrap(); + assert_eq!(save_within(&world, &image), Err(SaveFault::Exists)); + std::fs::write(&held, vec![0u8; PNG.len()]).unwrap(); + assert_eq!(save_within(&world, &image), Err(SaveFault::Exists)); +} + +#[test] +fn a_crashed_write_leaves_no_partial_final_name_and_a_retry_saves_in_full() { + use rustix::fs::{CWD, FileType, Mode, mknodat}; + let world = World::new(); + let big: Vec = b"\x89PNG\r\n\x1a\n".iter().copied().chain((0..20_000u32).map(|i| (i % 251) as u8)).collect(); + let image = world.put(ROOM, &big, "png"); + let final_name = format!("{}.png", image.sha256); + let part = format!(".{final_name}.part"); + + CRASH_AT.set(Some(8192)); + assert_eq!(world.save(&image), Err(SaveFault::WriteFailed)); + CRASH_AT.set(None); + assert_eq!(world.files(), [part.clone()], "only the temporary is left, and the final name is absent"); + assert!(std::fs::metadata(world.attachments().join(&part)).unwrap().len() < big.len() as u64); + + // The retry clears the leavings and saves in full. + assert!(world.save(&image).is_ok()); + assert_eq!(world.files(), [final_name.clone()]); + assert_eq!(std::fs::read(world.attachments().join(&final_name)).unwrap(), big); + + // A FIFO or a link at the temporary name does not stop the next save either. + std::fs::remove_file(world.attachments().join(&final_name)).unwrap(); + mknodat(CWD, world.attachments().join(&part), FileType::Fifo, Mode::RUSR | Mode::WUSR, 0).unwrap(); + assert!(save_within(&world, &image).is_ok()); + symlink("/dev/zero", world.attachments().join(&part)).unwrap(); + std::fs::remove_file(world.attachments().join(&final_name)).unwrap(); + assert!(save_within(&world, &image).is_ok()); + assert_eq!(world.files(), [final_name]); +} + +/// The child half of the mode test: a no-op unless the parent started it under a chosen umask. +#[test] +fn mode_child() { + let Ok(sandbox) = std::env::var("ATTACHMENT_MODE_CHILD") else { return }; + let world = World { store: tempfile::tempdir().unwrap(), sandbox: tempfile::TempDir::new_in(sandbox).unwrap() }; + let image = world.put(ROOM, PNG, "png"); + let path = world.save(&image).unwrap(); + let mode = std::fs::metadata(world.sandbox.path().join(path)).unwrap().permissions().mode() & 0o7777; + assert_eq!(mode, 0o600); + // The directory the saver makes is owner-only too. + assert_eq!(std::fs::metadata(world.attachments()).unwrap().permissions().mode() & 0o7777, 0o700); +} + +#[test] +fn the_saved_file_is_mode_0600_under_any_umask() { + let scratch = tempfile::tempdir().unwrap(); + let exe = std::env::current_exe().unwrap(); + for umask in ["0", "022", "077"] { + let status = std::process::Command::new("sh") + .args(["-c", &format!("umask {umask} && exec \"$0\" --exact attachment::tests::mode_child --nocapture")]) + .arg(&exe) + .env("ATTACHMENT_MODE_CHILD", scratch.path()) + .output() + .unwrap(); + assert!(status.status.success(), "umask {umask}: {}", String::from_utf8_lossy(&status.stdout)); + assert!(String::from_utf8_lossy(&status.stdout).contains("1 passed"), "the child ran: {}", String::from_utf8_lossy(&status.stdout)); + } +} diff --git a/crates/mind-body/src/chat_image.rs b/crates/mind-body/src/chat_image.rs index da26e2a..6e2f637 100644 --- a/crates/mind-body/src/chat_image.rs +++ b/crates/mind-body/src/chat_image.rs @@ -65,7 +65,13 @@ impl Skip { /// Loads `image`, which was delivered for `room_id`, from `store`. pub(crate) fn load(store: Option<&Path>, room_id: &str, image: &ChatImage) -> Result { - let store = store.ok_or(Skip::NoStore)?; + let (bytes, media_type) = load_bytes(store.ok_or(Skip::NoStore)?, room_id, image)?; + Ok(Image { media_type, base64: STANDARD.encode(&bytes) }) +} + +/// The verified bytes of `image` and the media type they sniff as: everything `load` checks, before it encodes. The one verifier; +/// the attachment saver calls it too, and holds no check of its own. +pub(crate) fn load_bytes(store: &Path, room_id: &str, image: &ChatImage) -> Result<(Vec, &'static str), Skip> { if image.size > MAX_BYTES { return Err(Skip::TooLarge); } @@ -108,7 +114,7 @@ pub(crate) fn load(store: Option<&Path>, room_id: &str, image: &ChatImage) -> Re if image.mime != media_type { return Err(Skip::MimeMismatch); } - Ok(Image { media_type, base64: STANDARD.encode(&bytes) }) + Ok((bytes, media_type)) } /// At most `MAX_BYTES + 1` bytes of `path`: a file that grew since the length check reads as another size than the diff --git a/crates/mind-body/src/lib.rs b/crates/mind-body/src/lib.rs index 57bcf25..24861fd 100644 --- a/crates/mind-body/src/lib.rs +++ b/crates/mind-body/src/lib.rs @@ -35,6 +35,7 @@ #![forbid(unsafe_code)] mod address; +mod attachment; pub mod chat_client; mod chat_image; pub mod chat_rudp;