use {
rustix::fs::{
chown, chownat, fallocate, fchown, fstat, futimens, link, linkat, openat, stat, symlinkat,
unlink, utimensat, AtFlags, FallocateFlags, Gid, Mode, OFlags, Timespec, Timestamps, Uid,
CWD, UTIME_OMIT,
},
smol::prelude::*,
std::{
io,
os::unix::fs::PermissionsExt,
path::{Path, PathBuf},
pin::Pin,
sync::Arc,
},
tempfile::TempPath,
};
pub trait Stage {
type Output;
type Target: StagingFileSystem + ?Sized;
fn stage<'a>(
&'a mut self,
fs: &'a Self::Target,
) -> Pin<Box<dyn Future<Output = io::Result<Self::Output>> + 'a>>;
}
pub trait StagingFile {
fn persist<P>(self, path: P) -> impl Future<Output = io::Result<()>>
where
P: AsRef<Path>;
}
#[allow(clippy::too_many_arguments)]
pub trait StagingFileSystem {
type File: StagingFile;
fn create_dir<P: AsRef<Path>>(
&self,
path: P,
uid: u32,
gid: u32,
mode: u32,
) -> impl Future<Output = io::Result<()>>;
fn create_dir_all<P: AsRef<Path>>(
&self,
path: P,
uid: u32,
gid: u32,
mode: u32,
) -> impl Future<Output = io::Result<()>>;
fn symlink<P: AsRef<Path>, Q: AsRef<Path>>(
&self,
target: P,
link: Q,
uid: u32,
gid: u32,
) -> impl Future<Output = io::Result<()>>;
fn hardlink<P: AsRef<Path>, Q: AsRef<Path>>(
&self,
target: P,
link: Q,
) -> impl Future<Output = io::Result<()>>;
fn create_file<'a, R: AsyncRead + Send + 'a>(
&'a self,
r: R,
uid: u32,
gid: u32,
mode: u32,
size: Option<usize>,
) -> impl Future<Output = io::Result<Self::File>> + 'a;
fn create_file_from_bytes<'a>(
&'a self,
r: &'a [u8],
uid: u32,
gid: u32,
mode: u32,
) -> impl Future<Output = io::Result<Self::File>> + 'a {
self.create_file(r, uid, gid, mode, Some(r.len()))
}
fn remove_file<P: AsRef<Path>>(&self, path: P) -> impl Future<Output = io::Result<()>>;
fn stage<T>(
&self,
artifact: Box<dyn Stage<Target = Self, Output = T>>,
) -> impl Future<Output = io::Result<T>>
where
T: Send + 'static;
}
#[derive(Clone)]
pub struct HostFileSystem {
root: Arc<Path>,
chown_allowed: bool,
}
fn clean_path(target: &Path) -> io::Result<&Path> {
let target = if target.has_root() {
target.strip_prefix("/").map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid path {:?}: {}", target.as_os_str(), err),
)
})?
} else {
target
};
for c in target.components() {
if c.as_os_str().eq("..") {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid path {:?}", target.as_os_str()),
));
}
}
Ok(target)
}
impl HostFileSystem {
pub async fn new<P: AsRef<Path>>(root: P, allow_chown: bool) -> io::Result<Self> {
smol::fs::create_dir_all(root.as_ref()).await?;
let root = smol::fs::canonicalize(root.as_ref()).await?;
Ok(Self {
root: root.into(),
chown_allowed: allow_chown,
})
}
fn target_path(&self, target: &Path) -> io::Result<PathBuf> {
Ok(self.root.join(clean_path(target)?))
}
}
pub struct HostFile {
base: Arc<Path>,
path: TempPath,
file: smol::fs::File,
}
impl StagingFile for HostFile {
async fn persist<P: AsRef<Path>>(self, name: P) -> io::Result<()> {
tracing::debug!(
"persisting file {} to {}",
self.path.display(),
name.as_ref().display()
);
let to = self.base.as_ref().join(clean_path(name.as_ref())?);
if to.parent().is_none() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid path {}", name.as_ref().display()),
));
}
let file = self.file;
let path = self.path;
match blocking::unblock(move || {
let file_meta = fstat(&file).inspect_err(|err| {
tracing::error!("failed to stat file {}: {}", path.display(), err)
})?;
let dir_met = stat(to.parent().unwrap()).inspect_err(|err| {
tracing::error!(
"failed to stat target directory {}: {}",
to.parent().unwrap_or_else(|| Path::new("/")).display(),
err
)
})?;
if file_meta.st_dev == dir_met.st_dev {
match linkat(&file, "", CWD, &to, AtFlags::EMPTY_PATH) {
Ok(()) => {
futimens(&file, &EPOCH)?;
}
Err(_) => {
}
}
return Ok::<_, io::Error>(None);
}
let target = openat(
CWD,
&to,
OFlags::CREATE | OFlags::WRONLY,
Mode::from_raw_mode(file_meta.st_mode),
)
.inspect_err(|err| {
tracing::error!("failed to open target file {}: {}", to.display(), err)
})?;
fchown(
&target,
Some(Uid::from_raw(file_meta.st_uid)),
Some(Gid::from_raw(file_meta.st_gid)),
)?;
Ok(Some((file, target)))
})
.await
.map_err(|err| {
io::Error::other(format!(
"failed to persist file {}: {}",
name.as_ref().display(),
err
))
})? {
None => Ok(()),
Some((mut src, dst)) => {
let mut dst: smol::fs::File = dst.into();
src.seek(smol::io::SeekFrom::Start(0)).await?;
smol::io::copy(&mut src, &mut dst).await?;
dst.sync_data().await?;
drop(src);
blocking::unblock(move || {
futimens(&dst, &EPOCH)?;
Ok(())
})
.await
}
}
}
}
const EPOCH: Timestamps = Timestamps {
last_modification: Timespec {
tv_sec: 0,
tv_nsec: 0,
},
last_access: Timespec {
tv_sec: 0,
tv_nsec: UTIME_OMIT,
},
};
fn mkdir(path: &std::path::Path, owner: Option<(u32, u32)>, mode: u32) -> io::Result<()> {
rustix::fs::mkdirat(CWD, path, Mode::from_raw_mode(mode))?;
if let Some((uid, gid)) = owner {
chown(path, Some(Uid::from_raw(uid)), Some(Gid::from_raw(gid)))?;
}
utimensat(CWD, path, &EPOCH, AtFlags::empty())?;
Ok(())
}
fn mkdir_rec(path: &std::path::Path, owner: Option<(u32, u32)>, mode: u32) -> io::Result<()> {
if path.is_dir() {
return Ok(());
}
match mkdir(path, owner, mode) {
Ok(()) => Ok(()),
Err(ref e) if e.kind() == io::ErrorKind::NotFound => {
let parent = path
.parent()
.ok_or_else(|| io::Error::other("failed to create tree: no parent"))?;
mkdir_rec(parent, owner, mode)?;
match mkdir(path, owner, mode) {
Ok(()) => Ok(()),
Err(_) if path.is_dir() => Ok(()),
Err(e) => Err(e),
}
}
Err(_) if path.is_dir() => Ok(()),
Err(e) => Err(e),
}
}
impl StagingFileSystem for HostFileSystem {
type File = HostFile;
fn create_dir<P: AsRef<Path>>(
&self,
path: P,
uid: u32,
gid: u32,
mode: u32,
) -> impl Future<Output = io::Result<()>> {
let target = self.target_path(path.as_ref());
tracing::debug!(
"creating directory {} at {} with mode {:o}",
path.as_ref().display(),
self.root.display(),
mode
);
let (owner, mode) = if self.chown_allowed {
(Some((uid, gid)), mode)
} else {
(
None,
mode & !(libc::S_ISUID | libc::S_ISGID | libc::S_ISVTX),
)
};
blocking::unblock(move || {
let target = target?;
mkdir(target.as_ref(), owner, mode).map_err(|e| {
io::Error::new(
e.kind(),
format!("failed to create directory {:?}: {}", target.as_os_str(), e),
)
})?;
utimensat(CWD, target, &EPOCH, AtFlags::empty())?;
Ok(())
})
}
fn create_dir_all<P: AsRef<Path>>(
&self,
path: P,
uid: u32,
gid: u32,
mode: u32,
) -> impl Future<Output = io::Result<()>> {
let target = self.target_path(path.as_ref());
tracing::debug!(
"creating directory tree {} at {} with mode {:o}",
path.as_ref().display(),
self.root.display(),
mode
);
let owner = if self.chown_allowed {
Some((uid, gid))
} else {
None
};
blocking::unblock(move || {
let target = target?;
mkdir_rec(target.as_ref(), owner, mode).map_err(|e| {
io::Error::new(
e.kind(),
format!(
"failed to create directory recursively {:?}: {}",
target.as_os_str(),
e
),
)
})
})
}
fn symlink<P: AsRef<Path>, Q: AsRef<Path>>(
&self,
target: P,
path: Q,
uid: u32,
gid: u32,
) -> impl Future<Output = io::Result<()>> {
let link = self.target_path(path.as_ref());
let target = target.as_ref().to_owned();
let chown_allowed = self.chown_allowed;
blocking::unblock(move || {
let link = link?;
symlinkat(target, CWD, &link).map_err(Into::<io::Error>::into)?;
if chown_allowed {
chownat(
CWD,
&link,
Some(Uid::from_raw(uid)),
Some(Gid::from_raw(gid)),
AtFlags::SYMLINK_NOFOLLOW,
)
.map_err(Into::<io::Error>::into)?;
}
utimensat(CWD, link, &EPOCH, AtFlags::SYMLINK_NOFOLLOW).map_err(Into::<io::Error>::into)
})
}
fn hardlink<P: AsRef<Path>, Q: AsRef<Path>>(
&self,
from: P,
to: Q,
) -> impl Future<Output = io::Result<()>> {
let from = self.target_path(from.as_ref());
let to = self.target_path(to.as_ref());
blocking::unblock(move || {
let from = from?;
let to = to?;
link(from, to).map_err(Into::into)
})
}
fn create_file<'a, R: AsyncRead + Send + 'a>(
&'a self,
r: R,
uid: u32,
gid: u32,
mode: u32,
size: Option<usize>,
) -> impl Future<Output = io::Result<Self::File>> + 'a {
tracing::debug!(
"creating temporary file in {} with mode {:o}",
self.root.display(),
mode
);
let root = self.root.clone();
async move {
let chown_allowed = self.chown_allowed;
let mode = if chown_allowed {
mode
} else {
mode & !(libc::S_ISUID | libc::S_ISGID | libc::S_ISVTX)
};
let (file, path) = blocking::unblock(move || {
let (file, path) = tempfile::Builder::new()
.permissions(smol::fs::Permissions::from_mode(mode))
.tempfile_in(&root)
.map(|f| f.into_parts())
.inspect_err(|err| {
tracing::error!(
"failed to create temporary file in {}: {}",
root.display(),
err
)
})?;
if let Some(size) = size {
if size > 0 {
fallocate(&file, FallocateFlags::KEEP_SIZE, 0, size as u64)
.inspect_err(|err| {
tracing::warn!(
"failed to preallocate file {}: {}",
path.display(),
err
)
})
.ok();
}
}
if chown_allowed {
fchown(&file, Some(Uid::from_raw(uid)), Some(Gid::from_raw(gid)))
.inspect_err(|err| {
tracing::error!(
"failed to set ownership of file {} to {}:{}: {}",
path.display(),
uid,
gid,
err
)
})
.ok();
}
Ok::<_, io::Error>((file, path))
})
.await?;
let mut file: smol::fs::File = file.into();
smol::io::copy(r, &mut file).await.inspect_err(|err| {
tracing::error!(
"failed to write to temporary file {}: {}",
path.display(),
err
)
})?;
file.sync_data().await.inspect_err(|err| {
tracing::error!("failed to sync temporary file {}: {}", path.display(), err)
})?;
Ok(HostFile {
base: Arc::clone(&self.root),
file,
path,
})
}
}
fn create_file_from_bytes<'a>(
&'a self,
r: &'a [u8],
uid: u32,
gid: u32,
mode: u32,
) -> impl Future<Output = io::Result<Self::File>> + 'a {
self.create_file(r, uid, gid, mode, Some(r.len()))
}
fn remove_file<P: AsRef<Path>>(&self, path: P) -> impl Future<Output = io::Result<()>> {
let target = self.target_path(path.as_ref());
blocking::unblock(move || unlink(target?).map_err(Into::into))
}
async fn stage<T>(
&self,
mut artifact: Box<dyn Stage<Target = Self, Output = T> + 'static>,
) -> io::Result<T>
where
T: Send + 'static,
{
artifact.as_mut().stage(self).await
}
}
#[derive(Clone, Debug)]
pub struct FileList {
out: std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
}
impl Default for FileList {
fn default() -> Self {
Self::new()
}
}
impl FileList {
pub fn new() -> Self {
Self {
out: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
}
}
pub async fn keep<P: AsRef<Path>>(self, path: P) -> io::Result<()> {
let mut file = smol::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(path)
.await?;
let mut list = self.out.lock().unwrap().drain().collect::<Vec<_>>();
list.sort();
file.write_all(list.join("\n").as_bytes()).await?;
file.flush().await?;
file.sync_data().await?;
Ok(())
}
}
pub struct FileListFile {
uid: u32,
gid: u32,
mode: u32,
size: u64,
out: std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
}
impl StagingFile for FileListFile {
async fn persist<P: AsRef<Path>>(self, path: P) -> io::Result<()> {
self.out.lock().unwrap().insert(format!(
"{} {:o} {} {} {}",
path.as_ref().as_os_str().to_string_lossy(),
self.mode,
self.uid,
self.gid,
self.size
));
Ok(())
}
}
impl StagingFileSystem for FileList {
type File = FileListFile;
async fn create_dir<P: AsRef<Path>>(
&self,
path: P,
uid: u32,
gid: u32,
mode: u32,
) -> io::Result<()> {
self.out.lock().unwrap().insert(format!(
"{} {:o} {} {}",
path.as_ref().as_os_str().to_string_lossy(),
mode,
uid,
gid,
));
Ok(())
}
async fn create_dir_all<P: AsRef<Path>>(
&self,
path: P,
uid: u32,
gid: u32,
mode: u32,
) -> io::Result<()> {
self.out.lock().unwrap().insert(format!(
"{} {:o} {} {}",
path.as_ref().as_os_str().to_string_lossy(),
mode,
uid,
gid,
));
Ok(())
}
async fn symlink<P: AsRef<Path>, Q: AsRef<Path>>(
&self,
target: P,
path: Q,
uid: u32,
gid: u32,
) -> io::Result<()> {
self.out.lock().unwrap().insert(format!(
"{} -> {} {} {}",
path.as_ref().as_os_str().to_string_lossy(),
target.as_ref().as_os_str().to_string_lossy(),
uid,
gid,
));
Ok(())
}
async fn hardlink<P: AsRef<Path>, Q: AsRef<Path>>(&self, from: P, to: Q) -> io::Result<()> {
self.out.lock().unwrap().insert(format!(
"{} -> {}",
from.as_ref().as_os_str().to_string_lossy(),
to.as_ref().as_os_str().to_string_lossy(),
));
Ok(())
}
async fn create_file<'a, R: AsyncRead + Send + 'a>(
&'a self,
r: R,
uid: u32,
gid: u32,
mode: u32,
_size: Option<usize>,
) -> io::Result<Self::File> {
let size = smol::io::copy(r, &mut smol::io::sink()).await?;
Ok(FileListFile {
mode,
uid,
gid,
size,
out: Arc::clone(&self.out),
})
}
async fn remove_file<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
self.out
.lock()
.unwrap()
.insert(format!("!{}", path.as_ref().as_os_str().to_string_lossy(),));
Ok(())
}
async fn stage<T>(
&self,
mut artifact: Box<dyn Stage<Target = Self, Output = T>>,
) -> io::Result<T>
where
T: Send + 'static,
{
artifact.as_mut().stage(self).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[allow(dead_code)]
trait ThreadSafeStagingFS: StagingFileSystem + Send + Sync + Clone {}
impl<T> ThreadSafeStagingFS for T
where
T: StagingFileSystem + Sync + Send + Clone,
T::File: Send,
{
}
use static_assertions::assert_impl_all;
assert_impl_all!(HostFile: Send, Sync);
assert_impl_all!(HostFileSystem: ThreadSafeStagingFS);
#[test]
fn mkdir_rec_creates_nested_directories() {
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join("a/b/c");
mkdir_rec(&target, None, 0o755).expect("mkdir_rec nested");
assert!(target.is_dir());
mkdir_rec(&target, None, 0o755).expect("mkdir_rec existing");
}
#[test]
fn mkdir_rec_handles_eexist_after_parent_creation() {
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join("x/y");
std::fs::create_dir_all(&target).expect("pre-create");
mkdir_rec(&target, None, 0o755).expect("mkdir_rec eexist");
}
#[test]
fn clean_path_rejects_parent_traversal_and_strips_root() {
let result = clean_path(Path::new("/foo/bar"));
assert_eq!(result.expect("clean /foo/bar"), Path::new("foo/bar"));
let result = clean_path(Path::new("foo/../etc/passwd"));
assert!(result.is_err());
let result = clean_path(Path::new("safe/path"));
assert_eq!(result.expect("clean safe/path"), Path::new("safe/path"));
}
#[test]
fn host_filesystem_target_path_and_clean_path_integration() {
let fs = smol::block_on(HostFileSystem::new(
tempfile::tempdir().expect("tempdir").path(),
false,
))
.expect("host fs");
let result = fs.target_path(Path::new("/etc/config.txt"));
assert!(result.is_ok());
assert!(result.unwrap().ends_with("etc/config.txt"));
let result = fs.target_path(Path::new("relative/path"));
assert!(result.is_ok());
let result = fs.target_path(Path::new("/foo/../etc/passwd"));
assert!(result.is_err());
}
}