use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::path::{Path, PathBuf};
use rustix::fs::{AtFlags, FileType, Mode, OFlags};
use rustix::io::Errno;
use crate::error::Error;
use crate::shared::printable;
use super::contain::{lands_within, resolve_deepest};
#[cfg(doc)]
use super::contained_artifacts;
use super::walk::{Walk, raced};
pub(super) fn artifact_source(
release: &Path,
env: &BTreeMap<String, String>,
artifact: &Path,
) -> PathBuf {
if let Some(target_dir) = env.get("CARGO_TARGET_DIR")
&& let Ok(rest) = artifact.strip_prefix("target")
{
return Path::new(target_dir).join(rest);
}
release.join(artifact)
}
pub(super) fn copy_artifact(
release: &Path,
roots: &[PathBuf; 2],
env: &BTreeMap<String, String>,
artifact: &Path,
) -> Result<(), Error> {
if artifact.is_absolute()
|| artifact
.components()
.any(|c| c == std::path::Component::ParentDir)
{
return Err(Error::Config(format!(
"build.artifacts entry `{}` would name a path outside the release",
printable(artifact.display())
)));
}
let from = artifact_source(release, env, artifact);
let to = release.join(artifact);
for (end, path) in [("destination", &to), ("source", &from)] {
if !lands_within(roots, path) {
return Err(Error::Config(format!(
"build.artifacts entry `{}` resolves its {end} to `{}`, which is \
outside the release and its build cache",
printable(artifact.display()),
resolve_deepest(path)
.unwrap_or_else(|| path.clone())
.display()
)));
}
if path.canonicalize().is_ok_and(|real| roots.contains(&real)) {
return Err(Error::Config(format!(
"build.artifacts entry `{}` names the release or its build cache as its \
{end}, which is a directory rather than a file the build produced",
printable(artifact.display())
)));
}
}
if from == to {
if !to.exists() {
return Err(Error::Config(format!(
"build.artifacts names `{}`, which the build did not leave there; \
either the command did not produce it or the entry is wrong",
printable(artifact.display())
)));
}
return Ok(());
}
let (Some(source_walk), Some(dest_walk)) = (
Walk::new(roots, artifact, &from),
Walk::new(roots, artifact, &to),
) else {
return Err(raced(artifact));
};
let source_dir = source_walk.parent(false)?;
let source = rustix::fs::openat(
&source_dir,
source_walk.leaf(),
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
Mode::empty(),
)
.map_err(|errno| {
if errno == Errno::LOOP {
raced(artifact)
} else {
Error::Io {
path: from.clone(),
source: io::Error::from(errno),
}
}
})?;
let mut source = fs::File::from(source);
let opened = source.metadata().map_err(Error::at(&from))?;
if !opened.file_type().is_file() {
return Err(Error::Config(format!(
"build.artifacts entry `{}` names a source that is not a regular file; \
artifacts are copied one file at a time",
printable(artifact.display())
)));
}
if fs::metadata(&to)
.is_ok_and(|there| there.dev() == opened.dev() && there.ino() == opened.ino())
{
return Ok(());
}
let dest_dir = dest_walk.parent(true)?;
let leaf = dest_walk.leaf();
match rustix::fs::statat(&dest_dir, leaf, AtFlags::SYMLINK_NOFOLLOW) {
Ok(there) if FileType::from_raw_mode(there.st_mode) != FileType::RegularFile => {
return Err(Error::Config(format!(
"build.artifacts entry `{}` names a destination that is not a regular file; \
refusing to replace it",
printable(artifact.display())
)));
}
Ok(_) => match rustix::fs::unlinkat(&dest_dir, leaf, AtFlags::empty()) {
Ok(()) => {}
Err(errno) if errno == Errno::NOENT => {}
Err(errno) => {
return Err(Error::Io {
path: to.clone(),
source: io::Error::from(errno),
});
}
},
Err(errno) if errno == Errno::NOENT => {}
Err(errno) => {
return Err(Error::Io {
path: to.clone(),
source: io::Error::from(errno),
});
}
}
let sink = rustix::fs::openat(
&dest_dir,
leaf,
OFlags::CREATE | OFlags::EXCL | OFlags::WRONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::from_bits_truncate(0o600),
)
.map_err(|errno| {
if errno == Errno::EXIST || errno == Errno::LOOP {
Error::Config(format!(
"build.artifacts entry `{}` had its destination replaced underneath the \
copy; refusing to write through it",
printable(artifact.display())
))
} else {
Error::Io {
path: to.clone(),
source: io::Error::from(errno),
}
}
})?;
let mut sink = fs::File::from(sink);
io::copy(&mut source, &mut sink).map_err(Error::at(&from))?;
let mode = opened.permissions().mode() & 0o777;
sink.set_permissions(fs::Permissions::from_mode(mode))
.map_err(Error::at(to))?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::super::contain::artifact_roots;
use super::super::test_support::{TEST_BUILD_BUDGET, build};
use super::super::{BuildSpec, run};
use super::*;
use crate::fixtures;
#[tokio::test]
async fn an_artifact_the_build_did_not_produce_is_a_failure() {
let rel = fixtures::fixture_release(&[]);
let spec = BuildSpec {
command: Some("true".into()),
env: BTreeMap::new(),
artifacts: vec![PathBuf::from("dist/app.js")],
};
let err = build(rel.path(), &spec)
.await
.expect_err("a declared artifact that is not there must fail the build");
assert!(
format!("{err}").contains("did not leave there"),
"must say what is missing and why: {err}"
);
}
#[tokio::test]
async fn a_copied_artifact_does_not_keep_setuid() {
use std::os::unix::fs::PermissionsExt;
let rel = fixtures::fixture_release(&[]);
let cache = fixtures::tempdir();
let built = cache.path().join("koji");
std::fs::write(&built, b"#!/bin/sh\necho hi\n").expect("the build's output");
std::fs::set_permissions(&built, std::fs::Permissions::from_mode(0o4755)).expect("chmod");
let spec = BuildSpec {
command: Some("true".into()),
env: [(
"CARGO_TARGET_DIR".into(),
cache.path().display().to_string(),
)]
.into(),
artifacts: vec![PathBuf::from("target/koji")],
};
run(
"web",
rel.path(),
&spec,
None,
&[],
cache.path(),
TEST_BUILD_BUDGET,
)
.await
.expect("builds and copies");
let mode = std::fs::metadata(rel.path().join("target/koji"))
.expect("copied")
.permissions()
.mode();
assert_eq!(
mode & 0o7777,
0o755,
"the copy must keep the permission bits and drop the privilege ones"
);
}
#[tokio::test]
async fn a_copied_artifact_keeps_the_source_permissions() {
use std::os::unix::fs::PermissionsExt;
let rel = fixtures::fixture_release(&[]);
let cache = fixtures::tempdir();
let built = cache.path().join("koji");
std::fs::write(&built, b"#!/bin/sh\necho hi\n").expect("the build's output");
std::fs::set_permissions(&built, std::fs::Permissions::from_mode(0o755)).expect("chmod");
let spec = BuildSpec {
command: Some("true".into()),
env: [(
"CARGO_TARGET_DIR".into(),
cache.path().display().to_string(),
)]
.into(),
artifacts: vec![PathBuf::from("target/koji")],
};
run(
"web",
rel.path(),
&spec,
None,
&[],
cache.path(),
TEST_BUILD_BUDGET,
)
.await
.expect("builds and copies");
let copied = rel.path().join("target/koji");
let mode = std::fs::metadata(&copied)
.expect("copied")
.permissions()
.mode();
assert_eq!(
mode & 0o777,
0o755,
"the copy must be executable, or shep cannot start the release it just built"
);
}
#[tokio::test]
async fn an_artifact_that_is_already_where_it_belongs_is_not_truncated() {
let rel = fixtures::fixture_release(&[]);
let cache = fixtures::tempdir();
std::fs::create_dir_all(cache.path().join("release")).expect("cache");
std::fs::write(cache.path().join("release/koji"), b"binary").expect("built");
std::os::unix::fs::symlink(cache.path(), rel.path().join("target")).expect("link");
let spec = BuildSpec {
command: Some("true".into()),
env: [(
"CARGO_TARGET_DIR".into(),
cache.path().display().to_string(),
)]
.into(),
artifacts: vec![PathBuf::from("target/release/koji")],
};
run(
"web",
rel.path(),
&spec,
None,
&[],
cache.path(),
TEST_BUILD_BUDGET,
)
.await
.expect("builds");
assert_eq!(
std::fs::read(cache.path().join("release/koji")).expect("still there"),
b"binary",
"the artifact must survive being copied onto itself"
);
}
#[tokio::test]
async fn an_artifact_naming_a_root_itself_is_refused_as_a_directory() {
let release = fixtures::tempdir();
let cache = fixtures::tempdir();
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 err = copy_artifact(release.path(), &roots, &env, Path::new("target"))
.expect_err("an artifact naming the cache root must be refused");
assert!(
!format!("{err}").contains("changed underneath"),
"nothing moved, so the refusal must not blame a race: {err}"
);
assert!(
format!("{err}").contains("rather than a file"),
"must say the entry names a directory: {err}"
);
}
#[tokio::test]
async fn a_directory_declared_as_an_artifact_creates_nothing() {
let release = fixtures::tempdir();
let cache = fixtures::tempdir();
std::fs::create_dir(cache.path().join("dist")).expect("the build's output dir");
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 err = copy_artifact(release.path(), &roots, &env, Path::new("target/dist"))
.expect_err("a directory is not an artifact");
assert!(
format!("{err}").contains("not a regular file"),
"must say what is wrong with the entry: {err}"
);
assert!(
!release.path().join("target").exists(),
"a refusal this early must not leave a directory tree in the release"
);
}
#[test]
fn a_named_pipe_declared_as_an_artifact_does_not_stop_the_copy() {
let release = fixtures::tempdir();
let cache = fixtures::tempdir();
let made = std::process::Command::new("mkfifo")
.arg(cache.path().join("koji"))
.status()
.expect("mkfifo");
assert!(
made.success(),
"the fixture needs a named pipe to prove anything"
);
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 (tell, heard) = std::sync::mpsc::channel();
let release_path = release.path().to_owned();
std::thread::spawn(move || {
let answer = copy_artifact(&release_path, &roots, &env, Path::new("target/koji"));
let _ = tell.send(format!("{:?}", answer.err()));
});
let answer = heard
.recv_timeout(Duration::from_secs(10))
.expect("the copy must answer rather than wait for a writer that may never come");
assert!(
answer.contains("not a regular file"),
"a named pipe is not an artifact: {answer}"
);
}
}