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:
parent
2ecb450f80
commit
390d519ed3
5 changed files with 486 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
150
crates/mind-body/src/attachment.rs
Normal file
150
crates/mind-body/src/attachment.rs
Normal 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;
|
||||
324
crates/mind-body/src/attachment/tests.rs
Normal file
324
crates/mind-body/src/attachment/tests.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
#![forbid(unsafe_code)]
|
||||
|
||||
mod address;
|
||||
mod attachment;
|
||||
pub mod chat_client;
|
||||
mod chat_image;
|
||||
pub mod chat_rudp;
|
||||
|
|
|
|||
Loading…
Reference in a new issue