339 lines
11 KiB
Rust
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();
|
|
}
|
|
}
|