use std::ffi::{OsStr, OsString}; use std::fs::File; use std::io; use std::io::Read; use std::os::fd::AsFd; use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::path::{Component, Path}; use rustix::fs::{ AtFlags, Mode, OFlags, RawDir, RenameFlags, ResolveFlags, Stat, Timestamps, linkat, mkdirat, openat, openat2, readlinkat, renameat_with, statat, symlinkat, unlinkat, utimensat, }; const RESOLVE: ResolveFlags = ResolveFlags::BENEATH .union(ResolveFlags::NO_MAGICLINKS) .union(ResolveFlags::NO_SYMLINKS); #[derive(Debug)] pub(crate) struct BackingRoot { directory: File, } impl BackingRoot { pub(crate) fn open(path: &Path) -> io::Result { let directory = File::from(openat2( rustix::fs::CWD, path, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, Mode::empty(), ResolveFlags::NO_MAGICLINKS | ResolveFlags::NO_SYMLINKS, )?); Ok(Self { directory }) } pub(crate) fn from_file(directory: File) -> io::Result { let stat = rustix::fs::fstat(directory.as_fd())?; if stat.st_mode & libc::S_IFMT != libc::S_IFDIR { return Err(io::Error::other("backing authority is not a directory")); } Ok(Self { directory }) } pub(crate) fn identity(&self) -> io::Result<(u64, u64)> { let stat = rustix::fs::fstat(self.directory.as_fd())?; Ok((stat.st_dev, stat.st_ino)) } pub(crate) fn duplicate(&self) -> io::Result { self.directory.try_clone() } pub(crate) fn stat(&self, relative: &Path) -> io::Result { if relative.as_os_str().is_empty() { return Ok(rustix::fs::fstat(self.directory.as_fd())?); } let (parent, name) = split(relative)?; let parent = self.open_directory(parent)?; Ok(statat(parent.as_fd(), name, AtFlags::SYMLINK_NOFOLLOW)?) } pub(crate) fn open_entry( &self, relative: &Path, flags: OFlags, mode: Mode, ) -> io::Result { if relative.as_os_str().is_empty() { return self.duplicate(); } let (parent, name) = split(relative)?; let parent = self.open_directory(parent)?; Ok(File::from(openat( parent.as_fd(), name, flags | OFlags::NOFOLLOW | OFlags::CLOEXEC, mode, )?)) } pub(crate) fn open_directory(&self, relative: &Path) -> io::Result { validate_relative(relative)?; let path = if relative.as_os_str().is_empty() { Path::new(".") } else { relative }; Ok(File::from(openat2( self.directory.as_fd(), path, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, Mode::empty(), RESOLVE, )?)) } pub(crate) fn ensure_parent_directories(&self, relative: &Path) -> io::Result<()> { let parent = relative.parent().unwrap_or_else(|| Path::new("")); validate_relative(parent)?; let mut current = self.duplicate()?; for component in parent.components() { let Component::Normal(name) = component else { return Err(invalid_path()); }; match mkdirat(current.as_fd(), name, Mode::from_raw_mode(0o700)) { Ok(()) | Err(rustix::io::Errno::EXIST) => {} Err(error) => return Err(error.into()), } current = File::from(openat( current.as_fd(), name, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty(), )?); } Ok(()) } pub(crate) fn mkdir(&self, relative: &Path, mode: Mode) -> io::Result<()> { let (parent, name) = split(relative)?; let parent = self.open_directory(parent)?; Ok(mkdirat(parent.as_fd(), name, mode)?) } pub(crate) fn unlink(&self, relative: &Path, directory: bool) -> io::Result<()> { let (parent, name) = split(relative)?; let parent = self.open_directory(parent)?; let flags = if directory { AtFlags::REMOVEDIR } else { AtFlags::empty() }; Ok(unlinkat(parent.as_fd(), name, flags)?) } pub(crate) fn rename( &self, source: &Path, destination: &Path, flags: RenameFlags, ) -> io::Result<()> { let (source_parent, source_name) = split(source)?; let (destination_parent, destination_name) = split(destination)?; let source_parent = self.open_directory(source_parent)?; let destination_parent = self.open_directory(destination_parent)?; Ok(renameat_with( source_parent.as_fd(), source_name, destination_parent.as_fd(), destination_name, flags, )?) } pub(crate) fn link(&self, source: &Path, destination: &Path) -> io::Result<()> { let (source_parent, source_name) = split(source)?; let (destination_parent, destination_name) = split(destination)?; let source_parent = self.open_directory(source_parent)?; let destination_parent = self.open_directory(destination_parent)?; Ok(linkat( source_parent.as_fd(), source_name, destination_parent.as_fd(), destination_name, AtFlags::empty(), )?) } pub(crate) fn symlink(&self, target: &Path, relative: &Path) -> io::Result<()> { let (parent, name) = split(relative)?; let parent = self.open_directory(parent)?; Ok(symlinkat(target, parent.as_fd(), name)?) } pub(crate) fn readlink(&self, relative: &Path) -> io::Result> { let (parent, name) = split(relative)?; let parent = self.open_directory(parent)?; Ok(readlinkat(parent.as_fd(), name, Vec::new())?.into_bytes()) } pub(crate) fn set_symlink_times( &self, relative: &Path, timestamps: &Timestamps, ) -> io::Result<()> { let (parent, name) = split(relative)?; let parent = self.open_directory(parent)?; Ok(utimensat( parent.as_fd(), name, timestamps, AtFlags::SYMLINK_NOFOLLOW, )?) } pub(crate) fn entries(&self, relative: &Path) -> io::Result> { Ok(self .typed_entries(relative)? .into_iter() .map(|(name, _)| name) .collect()) } pub(crate) fn typed_entries( &self, relative: &Path, ) -> io::Result> { let directory = self.open_directory(relative)?; let mut buffer = vec![std::mem::MaybeUninit::::uninit(); 32 * 1024]; let mut entries = RawDir::new(directory, &mut buffer); let mut names = Vec::new(); while let Some(entry) = entries.next() { let entry = entry?; let bytes = entry.file_name().to_bytes(); if bytes != b"." && bytes != b".." { names.push((OsString::from_vec(bytes.to_vec()), entry.file_type())); } } Ok(names) } pub(crate) fn read_file(&self, relative: &Path, maximum: usize) -> io::Result> { let mut file = self.open_entry(relative, OFlags::RDONLY | OFlags::NONBLOCK, Mode::empty())?; let metadata = file.metadata()?; let length = usize::try_from(metadata.len()) .map_err(|_| io::Error::other("file length is out of range"))?; if !metadata.file_type().is_file() || length > maximum { return Err(io::Error::other( "classification input is not a bounded regular file", )); } 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)?; if bytes.len() != length { return Err(io::Error::other( "classification input changed while being read", )); } Ok(bytes) } } pub(crate) fn validate_relative(path: &Path) -> io::Result<()> { if path.is_absolute() || path .components() .any(|component| !matches!(component, Component::Normal(_))) || path.as_os_str().as_bytes().contains(&0) { Err(invalid_path()) } else { Ok(()) } } pub(crate) fn validate_name(name: &OsStr) -> io::Result<()> { let bytes = name.as_bytes(); if bytes.is_empty() || bytes == b"." || bytes == b".." || bytes.contains(&0) || bytes.contains(&b'/') { Err(invalid_path()) } else { Ok(()) } } fn split(relative: &Path) -> io::Result<(&Path, &OsStr)> { validate_relative(relative)?; let name = relative.file_name().ok_or_else(invalid_path)?; validate_name(name)?; Ok((relative.parent().unwrap_or_else(|| Path::new("")), name)) } fn invalid_path() -> io::Error { io::Error::from_raw_os_error(libc::EINVAL) } #[cfg(test)] mod tests { use std::os::unix::fs::symlink; use std::sync::Arc; use rustix::fs::{RenameFlags, renameat_with}; use super::BackingRoot; #[test] fn descriptor_traversal_race_never_follows_backing_symlinks() { let base = std::env::current_dir() .unwrap() .join("target/ignored-workspace-tests") .join(format!("confinement-{}", std::process::id())); let root = base.join("root"); let outside = base.join("outside"); std::fs::create_dir_all(&root).unwrap(); std::fs::create_dir_all(&outside).unwrap(); std::fs::write(outside.join("sentinel"), b"outside").unwrap(); let raced = root.join("raced"); let candidate = root.join("candidate"); std::fs::create_dir(&raced).unwrap(); std::fs::write(raced.join("sentinel"), b"inside").unwrap(); symlink(&outside, &candidate).unwrap(); let authority = Arc::new(BackingRoot::open(&root).unwrap()); let exchange_thread = std::thread::spawn(move || { for _ in 0..128 { renameat_with( rustix::fs::CWD, &raced, rustix::fs::CWD, &candidate, RenameFlags::EXCHANGE, ) .unwrap(); renameat_with( rustix::fs::CWD, &raced, rustix::fs::CWD, &candidate, RenameFlags::EXCHANGE, ) .unwrap(); } }); for _ in 0..256 { if let Ok(bytes) = authority.read_file(std::path::Path::new("raced/sentinel"), 64) { assert_eq!(bytes, b"inside"); } } exchange_thread.join().unwrap(); assert_eq!(std::fs::read(outside.join("sentinel")).unwrap(), b"outside"); std::fs::remove_dir_all(base).unwrap(); } }