lazaret/crates/lazaret-ignored-workspace/src/backing.rs

339 lines
11 KiB
Rust

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<Self> {
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<Self> {
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<File> {
self.directory.try_clone()
}
pub(crate) fn stat(&self, relative: &Path) -> io::Result<Stat> {
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<File> {
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<File> {
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<Vec<u8>> {
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<Vec<OsString>> {
Ok(self
.typed_entries(relative)?
.into_iter()
.map(|(name, _)| name)
.collect())
}
pub(crate) fn typed_entries(
&self,
relative: &Path,
) -> io::Result<Vec<(OsString, rustix::fs::FileType)>> {
let directory = self.open_directory(relative)?;
let mut buffer = vec![std::mem::MaybeUninit::<u8>::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<Vec<u8>> {
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();
}
}