use std::fs::{File, OpenOptions}; use std::io::{self, Read, Seek, SeekFrom, Write}; use std::os::fd::{AsFd, OwnedFd}; use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::os::unix::fs::MetadataExt; use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; use lazaret_core::identity::{DeviceId, InodeId, MountId, MountIdentity, NamespaceIdentity}; use rustix::fs::{ AtFlags, FlockOperation, MemfdFlags, Mode, OFlags, RawDir, RenameFlags, SealFlags, StatxFlags, fchmod, fcntl_add_seals, fcntl_get_seals, flock, fstat, makedev, memfd_create, openat, renameat_with, statat, statx, unlinkat, }; static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0); #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct MailboxEntryIdentity { device: u64, inode: u64, } #[derive(Debug)] pub struct RegularFileMailbox { directory: File, } impl AsFd for RegularFileMailbox { fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> { self.directory.as_fd() } } impl RegularFileMailbox { /// # Errors /// Refuses entries that are not invoking-user-owned mode-0700 directories opened without links. pub fn openat_private( parent: impl AsFd, name: &std::ffi::OsStr, invoking_uid: u32, ) -> io::Result { validate_mailbox_name(name)?; let directory = File::from(openat( parent.as_fd(), name, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty(), )?); Self::from_private_directory(directory, invoking_uid) } /// # Errors /// Refuses paths that are not invoking-user-owned mode-0700 directories opened without links. pub fn open_private(path: &Path, invoking_uid: u32) -> io::Result { let directory = File::from(openat( rustix::fs::CWD, path, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty(), )?); Self::from_private_directory(directory, invoking_uid) } fn from_private_directory(directory: File, invoking_uid: u32) -> io::Result { let metadata = directory.metadata()?; if !metadata.file_type().is_dir() || metadata.uid() != invoking_uid || metadata.mode() & 0o7777 != 0o700 { return Err(io::Error::other( "mailbox directory is not invoking-user-owned mode 0700", )); } Ok(Self { directory }) } /// # Errors /// Refuses a path that is not a directory opened without following its final component. pub fn open(path: &Path) -> io::Result { let directory = File::from(openat( rustix::fs::CWD, path, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty(), )?); Ok(Self { directory }) } /// Publishes one complete regular-file record using a no-replace atomic rename. /// /// # Errors /// Refuses invalid names, existing destinations, or incomplete durable publication. pub fn publish( &self, name: &std::ffi::OsStr, bytes: &[u8], ) -> io::Result { self.publish_with_flags(name, bytes, RenameFlags::NOREPLACE) } /// Publishes one complete signed sender-owned record using an atomic rename. /// /// # Errors /// Refuses invalid names or incomplete durable publication. pub fn publish_replacing( &self, name: &std::ffi::OsStr, bytes: &[u8], ) -> io::Result { self.publish_with_flags(name, bytes, RenameFlags::empty()) } fn publish_with_flags( &self, name: &std::ffi::OsStr, bytes: &[u8], flags: RenameFlags, ) -> io::Result { validate_mailbox_name(name)?; let temporary_name = loop { let sequence = TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed); let candidate = std::ffi::OsString::from_vec( format!(".lazaret-{}-{sequence}.tmp", std::process::id()).into_bytes(), ); match openat( self.directory.as_fd(), &candidate, OFlags::CREATE | OFlags::EXCL | OFlags::WRONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::RUSR | Mode::WUSR, ) { Ok(fd) => break (candidate, fd), Err(rustix::io::Errno::EXIST) => {} Err(error) => return Err(error.into()), } }; let (temporary_name, fd) = temporary_name; let mut file = File::from(fd); let result = (|| { fchmod(file.as_fd(), Mode::RUSR | Mode::WUSR)?; file.write_all(bytes)?; file.sync_all()?; let metadata = file.metadata()?; if !metadata.file_type().is_file() || metadata.nlink() != 1 || usize::try_from(metadata.len()).ok() != Some(bytes.len()) { return Err(io::Error::other("mailbox temporary record changed")); } renameat_with( self.directory.as_fd(), &temporary_name, self.directory.as_fd(), name, flags, )?; self.directory.sync_all()?; Ok(MailboxEntryIdentity { device: metadata.dev(), inode: metadata.ino(), }) })(); if result.is_err() { let _ = unlinkat(self.directory.as_fd(), &temporary_name, AtFlags::empty()); } result } /// Reads and removes one exact regular-file entry after bounding it before allocation. /// /// # Errors /// Refuses links, non-regular files, oversize records, replacement, growth, or short reads. pub fn consume(&self, name: &std::ffi::OsStr, maximum: usize) -> io::Result> { let (bytes, identity) = self.read(name, maximum)?; self.remove_if_matches(name, identity)?; Ok(bytes) } /// Reads one exact regular-file entry after bounding it before allocation. /// /// # Errors /// Refuses links, non-regular files, oversize records, replacement, growth, or short reads. pub fn read( &self, name: &std::ffi::OsStr, maximum: usize, ) -> io::Result<(Vec, MailboxEntryIdentity)> { validate_mailbox_name(name)?; let mut file = File::from(openat( self.directory.as_fd(), name, OFlags::RDONLY | OFlags::NONBLOCK | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty(), )?); let before = file.metadata()?; let length = usize::try_from(before.len()) .map_err(|_| io::Error::other("mailbox record length is out of range"))?; if !before.file_type().is_file() || before.nlink() != 1 { return Err(io::Error::other( "mailbox entry is linked or is not a regular file", )); } if length > maximum { return Err(io::Error::other("mailbox record exceeds the size limit")); } let mut bytes = Vec::with_capacity(length.saturating_add(1)); Read::by_ref(&mut file) .take(u64::try_from(maximum).unwrap_or(u64::MAX).saturating_add(1)) .read_to_end(&mut bytes)?; let after = file.metadata()?; if bytes.len() != length || after.dev() != before.dev() || after.ino() != before.ino() || after.len() != before.len() || after.nlink() != 1 || !after.file_type().is_file() { return Err(io::Error::other("mailbox record changed while being read")); } Ok(( bytes, MailboxEntryIdentity { device: before.dev(), inode: before.ino(), }, )) } /// Removes a host-published record only while the pathname retains its exact identity. /// /// # Errors /// Refuses a missing or replaced pathname. pub fn remove_if_matches( &self, name: &std::ffi::OsStr, expected: MailboxEntryIdentity, ) -> io::Result<()> { validate_mailbox_name(name)?; let quarantine = loop { let sequence = TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed); let candidate = std::ffi::OsString::from_vec( format!(".lazaret-remove-{}-{sequence}.tmp", std::process::id()).into_bytes(), ); match renameat_with( self.directory.as_fd(), name, self.directory.as_fd(), &candidate, RenameFlags::NOREPLACE, ) { Ok(()) => break candidate, Err(rustix::io::Errno::EXIST) => {} Err(error) => return Err(error.into()), } }; let current = statat( self.directory.as_fd(), &quarantine, AtFlags::SYMLINK_NOFOLLOW, )?; if current.st_dev != expected.device || current.st_ino != expected.inode { let restore = renameat_with( self.directory.as_fd(), &quarantine, self.directory.as_fd(), name, RenameFlags::NOREPLACE, ); return match restore { Ok(()) => Err(io::Error::other("mailbox entry identity mismatch")), Err(error) => Err(io::Error::other(format!( "mailbox entry identity mismatch and restore failed: {error}" ))), }; } unlinkat(self.directory.as_fd(), &quarantine, AtFlags::empty())?; self.directory.sync_all() } /// # Errors /// Returns an error when the retained directory cannot be enumerated. pub fn names(&self) -> io::Result> { let fd: OwnedFd = openat( self.directory.as_fd(), ".", OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty(), )?; let mut buffer = vec![std::mem::MaybeUninit::::uninit(); 8192]; let mut directory = RawDir::new(fd, &mut buffer); let mut names = Vec::new(); while let Some(entry) = directory.next() { let entry = entry?; let name = entry.file_name().to_bytes(); if name != b"." && name != b".." { names.push(std::ffi::OsString::from_vec(name.to_vec())); } } Ok(names) } } fn validate_mailbox_name(name: &std::ffi::OsStr) -> io::Result<()> { let bytes = name.as_bytes(); if bytes.is_empty() || bytes.len() > 255 || bytes == b"." || bytes == b".." || bytes.contains(&0) || bytes.contains(&b'/') { Err(io::Error::other("invalid mailbox entry name")) } else { Ok(()) } } const REQUIRED_SEALS: SealFlags = SealFlags::SHRINK .union(SealFlags::GROW) .union(SealFlags::WRITE) .union(SealFlags::SEAL); /// # Errors /// Returns an error on incomplete writes, sealing failures, or seal mismatch. pub fn sealed_memfd(name: &str, bytes: &[u8]) -> io::Result { let fd = memfd_create(name, MemfdFlags::CLOEXEC | MemfdFlags::ALLOW_SEALING)?; let mut file = File::from(fd); file.write_all(bytes)?; file.flush()?; file.seek(SeekFrom::Start(0))?; fcntl_add_seals(file.as_fd(), REQUIRED_SEALS)?; require_write_sealed(file.as_fd())?; Ok(file) } /// # Errors /// Refuses descriptors without the complete immutable seal set. pub fn require_write_sealed(fd: impl AsFd) -> io::Result<()> { let seals = fcntl_get_seals(fd)?; if seals.contains(REQUIRED_SEALS) { Ok(()) } else { Err(io::Error::other("memfd lacks required immutable seals")) } } /// # Errors /// Returns an error when the nonblocking exclusive lock is unavailable. pub fn try_exclusive_flock(file: File) -> io::Result { flock(file.as_fd(), FlockOperation::NonBlockingLockExclusive)?; Ok(ExclusiveFileLock(file)) } /// # Errors /// Returns an error when the nonblocking shared lock is unavailable. pub fn try_shared_flock(file: File) -> io::Result { flock(file.as_fd(), FlockOperation::NonBlockingLockShared)?; Ok(SharedFileLock(file)) } #[derive(Debug)] pub struct ExclusiveFileLock(File); impl AsFd for ExclusiveFileLock { fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> { self.0.as_fd() } } #[derive(Debug)] pub struct SharedFileLock(File); impl AsFd for SharedFileLock { fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> { self.0.as_fd() } } /// # Errors /// Returns an error unless the replacement and containing directory are synced. pub fn atomic_replace(path: &Path, bytes: &[u8]) -> io::Result<()> { atomic_replace_with(path, |temporary| temporary.write_all(bytes)) } fn atomic_replace_with(path: &Path, write: Write) -> io::Result<()> where Write: FnOnce(&mut File) -> io::Result<()>, { let parent = path .parent() .ok_or_else(|| io::Error::other("replacement path has no parent"))?; let file_name = path .file_name() .ok_or_else(|| io::Error::other("replacement path has no file name"))?; let mut temporary = None; for _ in 0..128 { let sequence = TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed); let mut name = Vec::with_capacity(file_name.as_bytes().len() + 48); name.extend_from_slice(b"."); name.extend_from_slice(file_name.as_bytes()); name.extend_from_slice(format!(".lazaret-{}-{sequence}", std::process::id()).as_bytes()); let path = parent.join(std::ffi::OsString::from_vec(name)); match OpenOptions::new().create_new(true).write(true).open(&path) { Ok(file) => { temporary = Some((path, file)); break; } Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} Err(error) => return Err(error), } } let (temporary_path, mut temporary_file) = temporary.ok_or_else(|| io::Error::other("temporary replacement name exhausted"))?; let result = (|| { write(&mut temporary_file)?; temporary_file.sync_all()?; std::fs::rename(&temporary_path, parent.join(file_name))?; OpenOptions::new().read(true).open(parent)?.sync_all() })(); if result.is_err() { let _ = std::fs::remove_file(temporary_path); } result } /// # Errors /// Returns an error when the namespace descriptor cannot be opened or inspected. pub fn namespace_identity(path: &Path) -> io::Result { let file = OpenOptions::new().read(true).open(path)?; let stat = fstat(file.as_fd())?; Ok(NamespaceIdentity { device: DeviceId::new(stat.st_dev), inode: InodeId::new(stat.st_ino), }) } /// # Errors /// Returns an error when the path has no exact readable mount identity. pub fn current_mount_identity(path: &Path) -> io::Result { let file = OpenOptions::new().read(true).open(path)?; mount_identity(file.as_fd()) } /// # Errors /// Refuses when `statx(AT_EMPTY_PATH)` cannot return complete same-descriptor evidence. pub fn mount_identity(fd: impl AsFd) -> io::Result { let stat = statx( fd.as_fd(), "", AtFlags::EMPTY_PATH, StatxFlags::BASIC_STATS | StatxFlags::MNT_ID, )?; let mask = StatxFlags::from_bits_retain(stat.stx_mask); if !mask.contains(StatxFlags::INO | StatxFlags::MNT_ID) { return Err(io::Error::other( "statx omitted required mount identity evidence", )); } Ok(MountIdentity { mount_id: MountId::new(stat.stx_mnt_id), device: DeviceId::new(makedev(stat.stx_dev_major, stat.stx_dev_minor)), inode: InodeId::new(stat.stx_ino), }) } /// # Errors /// Refuses missing, unreadable, replaced, renamed, or remounted path identity. pub fn require_mount_identity(path: &Path, expected: MountIdentity) -> io::Result<()> { if current_mount_identity(path)? == expected { Ok(()) } else { Err(io::Error::other("mount identity mismatch")) } } #[cfg(test)] mod tests { use std::fs::{File, OpenOptions}; use std::io::{Read, Write}; use std::os::unix::fs::symlink; use rustix::fs::{MemfdFlags, memfd_create}; use super::{ RegularFileMailbox, atomic_replace, current_mount_identity, namespace_identity, require_mount_identity, require_write_sealed, sealed_memfd, try_exclusive_flock, try_shared_flock, }; fn test_directory(name: &str) -> std::path::PathBuf { let parent = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../target/test-artifacts/linux-filesystem"); std::fs::create_dir_all(&parent).unwrap(); let parent = parent.canonicalize().unwrap(); let path = parent.join(format!( "lazaret-linux-{name}-{}-{}", std::process::id(), super::TEMPORARY_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed) )); std::fs::create_dir(&path).unwrap(); path } #[test] fn seals_memfd_after_complete_write() { let mut file = sealed_memfd("policy", b"complete bytes").unwrap(); let mut bytes = Vec::new(); file.read_to_end(&mut bytes).unwrap(); assert_eq!(bytes, b"complete bytes"); assert!(file.write_all(b"mutation").is_err()); } #[test] fn refuses_unsealed_memfd() { let fd = memfd_create("unsealed", MemfdFlags::ALLOW_SEALING).unwrap(); assert!(require_write_sealed(fd).is_err()); } #[test] fn atomically_replaces_and_identifies_mount() { let directory = test_directory("replace"); let path = directory.join("record"); OpenOptions::new() .create_new(true) .write(true) .open(&path) .unwrap() .write_all(b"old") .unwrap(); atomic_replace(&path, b"new").unwrap(); assert_eq!(std::fs::read(&path).unwrap(), b"new"); assert!(current_mount_identity(&path).is_ok()); std::fs::remove_dir_all(directory).unwrap(); } #[test] fn partial_replacement_write_preserves_durable_old_file() { let directory = test_directory("partial-replace"); let path = directory.join("record"); std::fs::write(&path, b"old").unwrap(); let result = super::atomic_replace_with(&path, |temporary| { temporary.write_all(b"partial")?; Err(std::io::Error::other("injected partial write")) }); assert!(result.is_err()); assert_eq!(std::fs::read(&path).unwrap(), b"old"); assert_eq!(std::fs::read_dir(&directory).unwrap().count(), 1); std::fs::remove_dir_all(directory).unwrap(); } #[test] fn exclusive_flock_refuses_competing_open_description() { let directory = test_directory("flock"); let path = directory.join("lock"); let first = OpenOptions::new() .create_new(true) .read(true) .write(true) .open(&path) .unwrap(); let second = OpenOptions::new() .read(true) .write(true) .open(&path) .unwrap(); let held = try_exclusive_flock(first).unwrap(); assert!(try_exclusive_flock(second).is_err()); drop(held); std::fs::remove_dir_all(directory).unwrap(); } #[test] fn shared_flock_excludes_reclaim_until_every_claim_closes() { let directory = test_directory("shared-flock"); let path = directory.join("artifact"); std::fs::create_dir(&path).unwrap(); let first = try_shared_flock(File::open(&path).unwrap()).unwrap(); let second = try_shared_flock(File::open(&path).unwrap()).unwrap(); assert!(try_exclusive_flock(File::open(&path).unwrap()).is_err()); drop(first); assert!(try_exclusive_flock(File::open(&path).unwrap()).is_err()); drop(second); assert!(try_exclusive_flock(File::open(&path).unwrap()).is_ok()); std::fs::remove_dir_all(directory).unwrap(); } #[test] fn namespace_identity_is_stable_for_same_namespace() { let first = namespace_identity(std::path::Path::new("/proc/self/ns/mnt")).unwrap(); let second = namespace_identity(std::path::Path::new("/proc/self/ns/mnt")).unwrap(); assert_eq!(first, second); } #[test] fn mount_identity_refuses_replaced_and_renamed_objects() { let directory = test_directory("mount-race"); let path = directory.join("object"); std::fs::write(&path, b"first").unwrap(); let expected = current_mount_identity(&path).unwrap(); let old = directory.join("old"); std::fs::rename(&path, &old).unwrap(); std::fs::write(&path, b"replacement").unwrap(); assert!(require_mount_identity(&path, expected).is_err()); assert!(require_mount_identity(&old, expected).is_ok()); std::fs::remove_dir_all(directory).unwrap(); } #[test] fn mailbox_publication_is_atomic_and_no_replace() { let directory = test_directory("mailbox-publish"); let mailbox = RegularFileMailbox::open(&directory).unwrap(); let name = std::ffi::OsStr::new("request.ready"); mailbox.publish(name, b"complete").unwrap(); assert!(mailbox.publish(name, b"replacement").is_err()); assert_eq!(std::fs::read(directory.join(name)).unwrap(), b"complete"); assert!( mailbox .names() .unwrap() .iter() .all(|entry| !entry.as_encoded_bytes().ends_with(b".tmp")) ); std::fs::remove_dir_all(directory).unwrap(); } #[test] fn sender_owned_mailbox_publication_atomically_replaces_stale_entry() { let directory = test_directory("mailbox-sender-replace"); let mailbox = RegularFileMailbox::open(&directory).unwrap(); let name = std::ffi::OsStr::new("request.ready"); std::fs::write(directory.join(name), b"stale").unwrap(); mailbox.publish_replacing(name, b"signed").unwrap(); assert_eq!(std::fs::read(directory.join(name)).unwrap(), b"signed"); assert!( mailbox .names() .unwrap() .iter() .all(|entry| !entry.as_encoded_bytes().ends_with(b".tmp")) ); std::fs::remove_dir_all(directory).unwrap(); } #[test] fn mailbox_refuses_links_nonregular_and_oversize_records() { let directory = test_directory("mailbox-hostile"); let mailbox = RegularFileMailbox::open(&directory).unwrap(); std::fs::write(directory.join("target"), b"target").unwrap(); symlink("target", directory.join("symlink.ready")).unwrap(); std::fs::hard_link(directory.join("target"), directory.join("hardlink.ready")).unwrap(); std::fs::create_dir(directory.join("directory.ready")).unwrap(); std::fs::write(directory.join("oversize.ready"), [0_u8; 17]).unwrap(); assert!( mailbox .consume(std::ffi::OsStr::new("symlink.ready"), 16) .is_err() ); assert!( mailbox .consume(std::ffi::OsStr::new("hardlink.ready"), 16) .is_err() ); assert!( mailbox .consume(std::ffi::OsStr::new("directory.ready"), 16) .is_err() ); assert!( mailbox .consume(std::ffi::OsStr::new("oversize.ready"), 16) .is_err() ); std::fs::remove_dir_all(directory).unwrap(); } #[test] fn mailbox_removes_only_the_identity_it_read() { let directory = test_directory("mailbox-replacement"); let mailbox = RegularFileMailbox::open(&directory).unwrap(); let name = std::ffi::OsStr::new("result.ready"); let identity = mailbox.publish(name, b"first").unwrap(); std::fs::rename(directory.join(name), directory.join("old")).unwrap(); std::fs::write(directory.join(name), b"foreign").unwrap(); assert!(mailbox.remove_if_matches(name, identity).is_err()); assert_eq!(std::fs::read(directory.join(name)).unwrap(), b"foreign"); std::fs::remove_dir_all(directory).unwrap(); } }