Attachment saver: verified chat image into the sandbox by directory fd; load_bytes cut out of load

attachment.rs (crate-private): save(store, sandbox, room_id, image) re-verifies through
chat_image::load_bytes (load keeps its signature and calls it), names the file
<sha256>.<ext> from the verified type, refuses a .git at the sandbox root or in
attachments/, and writes through rustix openat/linkat on held descriptors: temp name
.part unlinked first, 0600 in the create call, fsync, linkat (never replaces), bounded
nonblocking regular-file compare on EEXIST. cfg(windows) half returns WriteFailed.
rustix gains the fs feature; no crate added.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Eureka 2026-11-12 00:52:44 +00:00
commit 390d519ed3
5 changed files with 486 additions and 4 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,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/<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.
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),
}
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,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<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
}
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.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));
}
}

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

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