use std::ffi::{OsStr, OsString};
use std::io;
use std::os::fd::OwnedFd;
use std::os::unix::ffi::OsStringExt as _;
use std::path::{Path, PathBuf};
use rustix::fs::{Mode, OFlags};
use rustix::io::Errno;
use crate::error::Error;
use crate::shared::printable;
#[cfg(doc)]
use super::contain::{artifact_roots, lands_within, resolve_deepest};
const WALK_DIRECTORY: OFlags = OFlags::RDONLY
.union(OFlags::DIRECTORY)
.union(OFlags::NOFOLLOW)
.union(OFlags::CLOEXEC);
pub(super) fn raced(artifact: &Path) -> Error {
Error::Config(format!(
"build.artifacts entry `{}` changed underneath the check; refusing to copy it",
printable(artifact.display())
))
}
pub(super) struct Walk<'a> {
roots: &'a [PathBuf; 2],
root: PathBuf,
parents: Vec<OsString>,
leaf: OsString,
artifact: &'a Path,
path: PathBuf,
}
impl<'a> Walk<'a> {
pub(super) fn new(roots: &'a [PathBuf; 2], artifact: &'a Path, path: &Path) -> Option<Self> {
let mut probe = path.to_owned();
let mut tail = Vec::new();
let real = loop {
if let Ok(real) = probe.canonicalize() {
break real;
}
tail.push(probe.file_name()?.to_owned());
if !probe.pop() {
return None;
}
};
tail.reverse();
let root = roots.iter().find(|root| real.starts_with(root))?;
let mut rest: Vec<OsString> = real
.strip_prefix(root)
.ok()?
.components()
.map(|component| component.as_os_str().to_owned())
.collect();
rest.extend(tail);
let leaf = rest.pop()?;
Some(Self {
roots,
root: root.clone(),
parents: rest,
leaf,
artifact,
path: path.to_owned(),
})
}
pub(super) fn parent(&self, create: bool) -> Result<OwnedFd, Error> {
let mut dir = self.open_root(&self.root)?;
for component in &self.parents {
dir = self.step(&dir, component, create)?;
}
Ok(dir)
}
pub(super) fn leaf(&self) -> &OsStr {
&self.leaf
}
fn step(&self, dir: &OwnedFd, component: &OsStr, create: bool) -> Result<OwnedFd, Error> {
match rustix::fs::openat(dir, component, WALK_DIRECTORY, Mode::empty()) {
Ok(fd) => Ok(fd),
Err(errno) if errno == Errno::LOOP || errno == Errno::NOTDIR => {
self.follow(dir, component, errno)
}
Err(errno) if errno == Errno::NOENT && create => {
match rustix::fs::mkdirat(dir, component, Mode::from_bits_truncate(0o755)) {
Ok(()) => {}
Err(errno) if errno == Errno::EXIST => {}
Err(errno) => return Err(self.io(errno)),
}
rustix::fs::openat(dir, component, WALK_DIRECTORY, Mode::empty())
.map_err(|errno| self.refuse(errno))
}
Err(errno) => Err(self.io(errno)),
}
}
fn follow(&self, dir: &OwnedFd, component: &OsStr, original: Errno) -> Result<OwnedFd, Error> {
let Ok(target) = rustix::fs::readlinkat(dir, component, Vec::new()) else {
return Err(self.io(original));
};
let target = PathBuf::from(OsString::from_vec(target.into_bytes()));
let named = target
.canonicalize()
.ok()
.filter(|_| target.is_absolute())
.filter(|real| self.roots.iter().any(|root| root == real));
match named {
Some(real) => self.open_root(&real),
None => Err(raced(self.artifact)),
}
}
fn open_root(&self, root: &Path) -> Result<OwnedFd, Error> {
rustix::fs::open(root, WALK_DIRECTORY, Mode::empty()).map_err(|errno| self.io(errno))
}
fn io(&self, errno: Errno) -> Error {
Error::Io {
path: self.path.clone(),
source: io::Error::from(errno),
}
}
fn refuse(&self, errno: Errno) -> Error {
if errno == Errno::LOOP || errno == Errno::NOTDIR {
raced(self.artifact)
} else {
self.io(errno)
}
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::super::artifacts::copy_artifact;
use super::super::contain::artifact_roots;
use super::*;
use crate::fixtures;
#[test]
fn a_component_that_becomes_a_link_to_a_root_is_followed_to_that_root() {
let release = fixtures::tempdir();
let cache = fixtures::tempdir();
let roots = artifact_roots(release.path(), cache.path());
let artifact = PathBuf::from("target/koji");
let to = release.path().join(&artifact);
let walk = Walk::new(&roots, &artifact, &to).expect("the destination lands in the release");
std::os::unix::fs::symlink(cache.path(), release.path().join("target")).expect("link");
let dir = walk
.parent(false)
.expect("a link naming a root is followed");
rustix::fs::openat(
&dir,
walk.leaf(),
OFlags::CREATE | OFlags::EXCL | OFlags::WRONLY,
Mode::from_bits_truncate(0o600),
)
.expect("the walk ends somewhere writable");
assert!(
cache.path().join("koji").exists(),
"the walk must continue from the root the link named, not from the link"
);
}
#[test]
fn a_component_that_becomes_a_link_to_anywhere_else_is_refused() {
let release = fixtures::tempdir();
let cache = fixtures::tempdir();
let outside = fixtures::tempdir();
let roots = artifact_roots(release.path(), cache.path());
let artifact = PathBuf::from("target/koji");
let to = release.path().join(&artifact);
let walk = Walk::new(&roots, &artifact, &to).expect("the destination lands in the release");
std::os::unix::fs::symlink(outside.path(), release.path().join("target")).expect("link");
let err = walk
.parent(false)
.expect_err("a link naming neither root must be refused");
assert!(
format!("{err}").contains("changed underneath the check"),
"must say the name stopped meaning what it meant: {err}"
);
assert!(
!outside.path().join("koji").exists(),
"nothing may be created outside both roots"
);
}
#[test]
fn a_component_that_becomes_a_relative_link_is_refused() {
let release = fixtures::tempdir();
let cache = fixtures::tempdir();
let roots = artifact_roots(release.path(), cache.path());
let artifact = PathBuf::from("target/koji");
let to = release.path().join(&artifact);
let walk = Walk::new(&roots, &artifact, &to).expect("the destination lands in the release");
let sibling = Path::new("..").join(cache.path().file_name().expect("a name"));
std::os::unix::fs::symlink(&sibling, release.path().join("target")).expect("link");
let err = walk
.parent(false)
.expect_err("a relative link target must be refused");
assert!(
format!("{err}").contains("changed underneath the check"),
"must be the refusal, not the kernel's own error: {err}"
);
}
#[test]
fn a_parent_swapped_for_a_symlink_during_the_copy_cannot_carry_a_write_out() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let release = fixtures::tempdir();
let cache = fixtures::tempdir();
let outside = fixtures::tempdir();
std::fs::create_dir_all(cache.path().join("dist")).expect("build output dir");
std::fs::write(cache.path().join("dist/app.js"), b"ARTIFACT").expect("build output");
let roots = artifact_roots(release.path(), cache.path());
let env: BTreeMap<String, String> = [(
"CARGO_TARGET_DIR".to_owned(),
cache.path().display().to_string(),
)]
.into();
let artifact = PathBuf::from("target/dist/app.js");
let swapped = release.path().join("target/dist");
std::fs::create_dir_all(&swapped).expect("the destination's parent");
let stop = Arc::new(AtomicBool::new(false));
let racer = {
let (stop, swapped, elsewhere) = (
Arc::clone(&stop),
swapped.clone(),
outside.path().to_owned(),
);
std::thread::spawn(move || {
while !stop.load(Ordering::Relaxed) {
let _ = std::fs::remove_dir_all(&swapped);
let _ = std::os::unix::fs::symlink(&elsewhere, &swapped);
let _ = std::fs::remove_file(&swapped);
let _ = std::fs::create_dir_all(&swapped);
}
})
};
let escaped = outside.path().join("app.js");
let mut leaked = None;
let mut copied = 0_u32;
for _ in 0..2_000 {
if copy_artifact(release.path(), &roots, &env, &artifact).is_ok() {
copied += 1;
}
if escaped.exists() {
leaked = Some(std::fs::read(&escaped).unwrap_or_default());
break;
}
}
stop.store(true, Ordering::Relaxed);
racer.join().expect("the racer");
assert!(
leaked.is_none(),
"a swapped parent carried the copy outside both roots: {:?}",
leaked.as_deref().map(String::from_utf8_lossy)
);
assert!(
copied > 0,
"no attempt copied anything, so the invariant above proves nothing"
);
}
}