use std::fs::{File, OpenOptions}; use std::io::{self, Seek, SeekFrom, Write}; use std::os::fd::AsFd; use std::os::unix::ffi::{OsStrExt, OsStringExt}; 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, SealFlags, StatxFlags, fcntl_add_seals, fcntl_get_seals, flock, fstat, makedev, memfd_create, statx, }; static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0); 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)) } #[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(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::OpenOptions; use std::io::{Read, Write}; use rustix::fs::{MemfdFlags, memfd_create}; use super::{ atomic_replace, current_mount_identity, namespace_identity, require_mount_identity, require_write_sealed, sealed_memfd, try_exclusive_flock, }; 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(); } }