Add fail-closed Linux process and descriptor primitives
This commit is contained in:
parent
b34421efa1
commit
1468f6b841
1 changed files with 1725 additions and 15 deletions
268
crates/lazaret-linux/src/filesystem.rs
Normal file
268
crates/lazaret-linux/src/filesystem.rs
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
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<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};
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue