2026-07-14 22:08:29 +09:00
|
|
|
use std::fs::{File, OpenOptions};
|
2026-07-15 19:48:42 +09:00
|
|
|
use std::io::{self, Read, Seek, SeekFrom, Write};
|
|
|
|
|
use std::os::fd::{AsFd, OwnedFd};
|
2026-07-14 22:08:29 +09:00
|
|
|
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
2026-07-15 19:48:42 +09:00
|
|
|
use std::os::unix::fs::MetadataExt;
|
2026-07-14 22:08:29 +09:00
|
|
|
use std::path::Path;
|
|
|
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
|
|
|
|
|
|
use lazaret_core::identity::{DeviceId, InodeId, MountId, MountIdentity, NamespaceIdentity};
|
|
|
|
|
use rustix::fs::{
|
2026-07-15 19:48:42 +09:00
|
|
|
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,
|
2026-07-14 22:08:29 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
|
|
|
|
|
2026-07-15 19:48:42 +09:00
|
|
|
#[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<Self> {
|
|
|
|
|
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<Self> {
|
|
|
|
|
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<Self> {
|
|
|
|
|
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<Self> {
|
|
|
|
|
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],
|
2026-07-18 04:03:39 +09:00
|
|
|
) -> io::Result<MailboxEntryIdentity> {
|
|
|
|
|
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<MailboxEntryIdentity> {
|
|
|
|
|
self.publish_with_flags(name, bytes, RenameFlags::empty())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn publish_with_flags(
|
|
|
|
|
&self,
|
|
|
|
|
name: &std::ffi::OsStr,
|
|
|
|
|
bytes: &[u8],
|
|
|
|
|
flags: RenameFlags,
|
2026-07-15 19:48:42 +09:00
|
|
|
) -> io::Result<MailboxEntryIdentity> {
|
|
|
|
|
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,
|
2026-07-18 04:03:39 +09:00
|
|
|
flags,
|
2026-07-15 19:48:42 +09:00
|
|
|
)?;
|
|
|
|
|
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<Vec<u8>> {
|
|
|
|
|
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<u8>, 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<Vec<std::ffi::OsString>> {
|
|
|
|
|
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::<u8>::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(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 22:08:29 +09:00
|
|
|
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<File> {
|
|
|
|
|
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<ExclusiveFileLock> {
|
|
|
|
|
flock(file.as_fd(), FlockOperation::NonBlockingLockExclusive)?;
|
|
|
|
|
Ok(ExclusiveFileLock(file))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
pub struct ExclusiveFileLock(File);
|
|
|
|
|
|
|
|
|
|
impl AsFd for ExclusiveFileLock {
|
|
|
|
|
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<Write>(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<NamespaceIdentity> {
|
|
|
|
|
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<MountIdentity> {
|
|
|
|
|
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<MountIdentity> {
|
|
|
|
|
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::OpenOptions;
|
|
|
|
|
use std::io::{Read, Write};
|
2026-07-15 19:48:42 +09:00
|
|
|
use std::os::unix::fs::symlink;
|
2026-07-14 22:08:29 +09:00
|
|
|
|
|
|
|
|
use rustix::fs::{MemfdFlags, memfd_create};
|
|
|
|
|
|
|
|
|
|
use super::{
|
2026-07-15 19:48:42 +09:00
|
|
|
RegularFileMailbox, atomic_replace, current_mount_identity, namespace_identity,
|
|
|
|
|
require_mount_identity, require_write_sealed, sealed_memfd, try_exclusive_flock,
|
2026-07-14 22:08:29 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
fn test_directory(name: &str) -> std::path::PathBuf {
|
|
|
|
|
let path = std::env::temp_dir().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 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();
|
|
|
|
|
}
|
2026-07-15 19:48:42 +09:00
|
|
|
|
|
|
|
|
#[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()
|
2026-07-18 04:03:39 +09:00
|
|
|
.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()
|
2026-07-15 19:48:42 +09:00
|
|
|
.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();
|
|
|
|
|
}
|
2026-07-14 22:08:29 +09:00
|
|
|
}
|