use std::collections::BTreeMap;
use std::env;
use std::ffi::{OsStr, OsString};
use std::fmt;
use std::fs;
use std::io;
use std::os::fd::OwnedFd;
use std::os::unix::ffi::OsStringExt as _;
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::time::Duration;
use rustix::fs::{AtFlags, FileType, Mode, OFlags};
use rustix::io::Errno;
use serde::Deserialize;
use std::os::unix::process::CommandExt as _;
use tokio::process::Command;
use tokio::time::timeout;
use crate::error::Error;
use crate::shared::printable;
const BASE_ENV: &[&str] = &["PATH", "HOME", "LANG", "LC_ALL", "TZ"];
#[derive(Clone, PartialEq, Eq, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct BuildSpec {
pub command: Option<String>,
pub env: BTreeMap<String, String>,
#[serde(deserialize_with = "contained_artifacts")]
pub artifacts: Vec<PathBuf>,
}
fn contained_artifacts<'de, D>(deserializer: D) -> Result<Vec<PathBuf>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
let artifacts = Vec::<PathBuf>::deserialize(deserializer)?;
for artifact in &artifacts {
if artifact.as_os_str().is_empty() {
return Err(D::Error::custom(
"build.artifacts has an empty entry, which names nothing; remove it or name a \
path relative to the release",
));
}
if artifact.is_absolute() {
return Err(D::Error::custom(format!(
"build.artifacts entry `{}` is an absolute path; artifacts are \
copied into the release and must be relative to it",
printable(artifact.display())
)));
}
if artifact
.components()
.any(|c| c == std::path::Component::ParentDir)
{
return Err(D::Error::custom(format!(
"build.artifacts entry `{}` contains `..`, which would name a \
path outside the release",
printable(artifact.display())
)));
}
}
Ok(artifacts)
}
impl fmt::Debug for BuildSpec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BuildSpec")
.field("command", &self.command)
.field("env", &format_args!("<{} vars>", self.env.len()))
.field("artifacts", &self.artifacts)
.finish()
}
}
async fn resolve_id(release: &Path, user: &str, flag: &str, kind: &str) -> Result<u32, Error> {
let output = Command::new("id")
.arg(flag)
.arg(user)
.output()
.await
.map_err(Error::at(release))?;
if !output.status.success() {
return Err(Error::Config(format!(
"the app's `user = {user:?}` does not resolve to a {kind} on this host (`id {flag} \
{user}` failed); create that account, or pin a different one in \
Flockfile.override.toml"
)));
}
String::from_utf8_lossy(&output.stdout)
.trim()
.parse()
.map_err(|_| Error::Config(format!("`id {flag} {user}` did not print a {kind}")))
}
async fn effective_uid() -> Option<u32> {
let output = Command::new("id").arg("-u").output().await.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout).trim().parse().ok()
}
async fn root_warning(sheep: &str, as_user: Option<&str>) -> Option<String> {
if as_user.is_some() {
return None;
}
root_build_warning(sheep, effective_uid().await?, as_user)
}
fn root_build_warning(sheep: &str, euid: u32, as_user: Option<&str>) -> Option<String> {
const ROOT: u32 = 0;
(euid == ROOT && as_user.is_none()).then(|| {
format!(
"shep-deploy: warning: {sheep}'s build is about to run as root, because its app sets \
no `user`. The build command comes from the deployed repository, so whatever that \
repository can be made to run, it runs as root. Set `user` on the app to build as \
that user instead."
)
})
}
async fn uid_for(release: &Path, user: &str) -> Result<u32, Error> {
resolve_id(release, user, "-u", "uid").await
}
async fn gid_for(release: &Path, user: &str) -> Result<u32, Error> {
resolve_id(release, user, "-g", "gid").await
}
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)
}
fn resolve_deepest(path: &Path) -> Option<PathBuf> {
let mut probe = path.to_owned();
loop {
if let Ok(real) = probe.canonicalize() {
return Some(real);
}
if !probe.pop() {
return None;
}
}
}
fn lands_within(roots: &[PathBuf], candidate: &Path) -> bool {
resolve_deepest(candidate).is_some_and(|real| roots.iter().any(|root| real.starts_with(root)))
}
const WALK_DIRECTORY: OFlags = OFlags::RDONLY
.union(OFlags::DIRECTORY)
.union(OFlags::NOFOLLOW)
.union(OFlags::CLOEXEC);
fn raced(artifact: &Path) -> Error {
Error::Config(format!(
"build.artifacts entry `{}` changed underneath the check; refusing to copy it",
printable(artifact.display())
))
}
struct Walk<'a> {
roots: &'a [PathBuf; 2],
root: PathBuf,
parents: Vec<OsString>,
leaf: OsString,
artifact: &'a Path,
path: PathBuf,
}
impl<'a> Walk<'a> {
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(),
})
}
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)
}
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)
}
}
}
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(())
}
pub async fn run(
sheep: &str,
release: &Path,
spec: &BuildSpec,
as_user: Option<&str>,
passthrough: &[String],
cache: &Path,
budget: Duration,
) -> Result<(), Error> {
let Some(command) = spec.command.as_deref() else {
return Ok(());
};
if let Some(warning) = root_warning(sheep, as_user).await {
eprintln!("{warning}");
}
if let Some(target_dir) = spec.env.get("CARGO_TARGET_DIR")
&& !Path::new(target_dir).is_absolute()
{
return Err(Error::Config(format!(
"[dog.deploy.build] env sets CARGO_TARGET_DIR = {target_dir:?}, which is not an \
absolute path; a relative one is resolved against whatever directory this dog \
is running in, not against the release. Name the directory in full"
)));
}
let mut child = Command::new("sh");
child.arg("-c").arg(command);
child.current_dir(release);
child.env_clear();
for key in BASE_ENV {
if let Some(value) = env::var_os(key) {
child.env(key, value);
}
}
for key in passthrough {
match env::var_os(key) {
Some(value) => {
child.env(key, value);
}
None => eprintln!(
"shep-deploy: {sheep}: passthrough names {key}, which is not set in this dog's \
environment, so the build does not get it"
),
}
}
child.envs(&spec.env);
if let Some(user) = as_user {
child.gid(gid_for(release, user).await?);
child.uid(uid_for(release, user).await?);
}
let mut holder = std::process::Command::new("sh");
holder
.args(["-c", "read _"])
.env_clear()
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.process_group(0);
if let Some(path) = env::var_os("PATH") {
holder.env("PATH", path);
}
let mut holder = holder.spawn().map_err(Error::at(release))?;
let group = holder.id();
let group_id = i32::try_from(group).map_err(|_| Error::Io {
path: release.to_owned(),
source: std::io::Error::other("the group holder's pid does not fit a pid_t"),
})?;
child.process_group(group_id);
let mut child = match child.spawn() {
Ok(child) => child,
Err(source) => {
crate::shared::kill_group(group);
let _ = holder.wait();
return Err(Error::Io {
path: release.to_owned(),
source,
});
}
};
let status = match timeout(budget, child.wait()).await {
Ok(waited) => waited,
Err(_) => {
abandon(&mut child, group).await;
let _ = holder.wait();
return Err(Error::BuildTimedOut { after: budget });
}
};
crate::shared::kill_group(group);
let _ = holder.wait();
let status = status.map_err(Error::at(release))?;
if !status.success() {
return Err(Error::Build {
status: status.code(),
});
}
let roots = artifact_roots(release, cache);
let (release, env, artifacts) = (release.to_owned(), spec.env.clone(), spec.artifacts.clone());
crate::shared::off_thread(move || {
for artifact in &artifacts {
copy_artifact(&release, &roots, &env, artifact)?;
}
Ok(())
})
.await
}
fn artifact_roots(release: &Path, cache: &Path) -> [PathBuf; 2] {
[
release
.canonicalize()
.unwrap_or_else(|_| release.to_owned()),
cache.canonicalize().unwrap_or_else(|_| cache.to_owned()),
]
}
async fn abandon(child: &mut tokio::process::Child, group: u32) {
crate::shared::kill_group(group);
let _ = child.kill().await;
}
#[cfg(test)]
mod tests {
#[tokio::test]
async fn a_successful_build_does_not_leave_its_background_job_running() {
let rel = fixtures::fixture_release(&[]);
let spec = BuildSpec {
command: Some("sleep 400 & echo $! > bg.pid".into()),
env: BTreeMap::new(),
artifacts: vec![],
};
build(rel.path(), &spec).await.expect("this build exits 0");
let pid = std::fs::read_to_string(rel.path().join("bg.pid"))
.expect("the build wrote its background job's pid");
assert!(
!still_running(pid.trim()),
"the background job (pid {}) outlived the build that started it",
pid.trim()
);
}
#[tokio::test]
async fn a_build_that_never_finishes_is_abandoned() {
let rel = fixtures::fixture_release(&[]);
let spec = BuildSpec {
command: Some("sleep 600 & echo $! > descendant.pid; wait".into()),
env: BTreeMap::new(),
artifacts: vec![],
};
let err = run(
"web",
rel.path(),
&spec,
None,
&[],
fixtures::tempdir().path(),
Duration::from_secs(2),
)
.await
.expect_err("a build that never finishes must be abandoned");
assert!(
matches!(err, Error::BuildTimedOut { .. }),
"must say it timed out rather than that it failed: {err:?}"
);
assert!(
format!("{err}").contains("build_timeout"),
"must name the knob that changes it: {err}"
);
let pid = std::fs::read_to_string(rel.path().join("descendant.pid")).expect(
"the build never got far enough to write its descendant's pid, so this \
proves nothing about the group kill either way",
);
assert!(
!still_running(pid.trim()),
"the backgrounded descendant (pid {}) outlived the build it belonged to",
pid.trim()
);
}
#[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"
);
}
fn still_running(pid: &str) -> bool {
for _ in 0..50 {
let alive = std::process::Command::new("kill")
.args(["-0", pid])
.status()
.expect("kill -0")
.success();
if !alive {
return false;
}
std::thread::sleep(Duration::from_millis(20));
}
true
}
const TEST_BUILD_BUDGET: Duration = Duration::from_secs(60);
async fn build(rel: &Path, spec: &BuildSpec) -> Result<(), Error> {
run(
"web",
rel,
spec,
None,
&[],
fixtures::tempdir().path(),
TEST_BUILD_BUDGET,
)
.await
}
use crate::fixtures;
use super::*;
fn current_username() -> String {
let output = std::process::Command::new("id")
.arg("-un")
.output()
.expect("id -un runs");
assert!(output.status.success(), "id -un must succeed");
String::from_utf8(output.stdout)
.expect("id -un prints utf-8")
.trim()
.to_owned()
}
fn current_uid() -> u32 {
let output = std::process::Command::new("id")
.arg("-u")
.output()
.expect("id -u runs");
assert!(output.status.success(), "id -u must succeed");
String::from_utf8(output.stdout)
.expect("id -u prints utf-8")
.trim()
.parse()
.expect("id -u prints a number")
}
fn current_gid() -> u32 {
let output = std::process::Command::new("id")
.arg("-g")
.output()
.expect("id -g runs");
assert!(output.status.success(), "id -g must succeed");
String::from_utf8(output.stdout)
.expect("id -g prints utf-8")
.trim()
.parse()
.expect("id -g prints a number")
}
#[test]
fn only_a_rootless_build_as_root_is_warned_about() {
assert!(root_build_warning("web", 0, None).is_some());
assert!(root_build_warning("web", 0, Some("reactmap")).is_none());
assert!(root_build_warning("web", 501, None).is_none());
assert!(root_build_warning("web", 501, Some("reactmap")).is_none());
}
#[test]
fn the_root_warning_names_the_sheep_and_the_way_out() {
let warning = root_build_warning("bpm", 0, None).expect("warns");
assert!(warning.contains("bpm"), "{warning}");
assert!(warning.contains("root"), "{warning}");
assert!(warning.contains("repository"), "{warning}");
assert!(warning.contains("`user`"), "{warning}");
}
#[tokio::test]
async fn the_effective_uid_can_be_read() {
assert!(effective_uid().await.is_some());
}
#[tokio::test]
async fn the_warning_reads_this_process_and_agrees_with_the_decision() {
let euid = effective_uid().await.expect("a uid");
assert!(root_warning("web", Some("reactmap")).await.is_none());
assert_eq!(
root_warning("web", None).await.is_some(),
euid == 0,
"warned about as uid {euid}"
);
}
#[tokio::test]
async fn a_failing_build_is_an_error() {
let rel = fixtures::fixture_release(&[]);
let spec = BuildSpec {
command: Some("exit 3".into()),
..Default::default()
};
assert!(build(rel.path(), &spec).await.is_err());
}
#[tokio::test]
async fn an_absent_build_command_is_not_an_error() {
let rel = fixtures::fixture_release(&[]);
let spec = BuildSpec::default();
assert!(build(rel.path(), &spec).await.is_ok());
}
#[tokio::test]
async fn declared_artifacts_are_copied_into_the_release() {
let rel = fixtures::fixture_release(&[]);
let cache = fixtures::tempdir();
std::fs::create_dir_all(cache.path().join("release")).unwrap();
std::fs::write(cache.path().join("release/koji"), b"binary").unwrap();
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!(rel.path().join("target/release/koji").exists());
}
#[tokio::test]
async fn the_dogs_own_environment_does_not_reach_a_build() {
assert!(
std::env::var("CARGO_PKG_NAME").is_ok(),
"the probe variable must exist in this process or the test proves nothing"
);
let rel = fixtures::fixture_release(&[]);
let spec = BuildSpec {
command: Some("printenv CARGO_PKG_NAME > leaked.txt; true".into()),
env: BTreeMap::new(),
artifacts: vec![],
};
build(rel.path(), &spec).await.expect("builds");
let leaked = std::fs::read_to_string(rel.path().join("leaked.txt")).unwrap_or_default();
assert!(
leaked.trim().is_empty(),
"the build saw CARGO_PKG_NAME = {leaked:?}, so the environment was inherited"
);
}
#[tokio::test]
async fn the_base_environment_reaches_the_build() {
let rel = fixtures::fixture_release(&[]);
let spec = BuildSpec {
command: Some("printenv PATH > path.txt; printenv HOME > home.txt; true".into()),
env: BTreeMap::new(),
artifacts: vec![],
};
build(rel.path(), &spec).await.expect("builds");
for name in ["path.txt", "home.txt"] {
let value = std::fs::read_to_string(rel.path().join(name)).unwrap_or_default();
assert!(
!value.trim().is_empty(),
"{name} is empty, so a BASE_ENV variable did not reach the build"
);
}
}
#[tokio::test]
async fn a_named_passthrough_variable_reaches_the_build() {
let rel = fixtures::fixture_release(&[]);
let spec = BuildSpec {
command: Some("printenv CARGO_PKG_NAME > passed.txt; true".into()),
env: BTreeMap::new(),
artifacts: vec![],
};
run(
"web",
rel.path(),
&spec,
None,
&["CARGO_PKG_NAME".to_owned()],
fixtures::tempdir().path(),
TEST_BUILD_BUDGET,
)
.await
.expect("builds");
assert_eq!(
std::fs::read_to_string(rel.path().join("passed.txt"))
.expect("the build wrote it")
.trim(),
"shep-deploy"
);
}
#[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 a_source_outside_the_release_and_cache_is_refused() {
let rel = fixtures::fixture_release(&[]);
let elsewhere = fixtures::tempdir();
std::fs::write(elsewhere.path().join("id_rsa"), b"PRIVATE KEY").expect("secret");
let spec = BuildSpec {
command: Some("true".into()),
env: [(
"CARGO_TARGET_DIR".into(),
elsewhere.path().display().to_string(),
)]
.into(),
artifacts: vec![PathBuf::from("target/id_rsa")],
};
let err = build(rel.path(), &spec)
.await
.expect_err("a source outside the tree must be refused");
assert!(
format!("{err}").contains("outside the release"),
"must say why: {err}"
);
assert!(
!rel.path().join("target/id_rsa").exists(),
"nothing may be read into the release"
);
}
#[tokio::test]
async fn a_committed_target_symlink_cannot_carry_a_write_out() {
let rel = fixtures::fixture_release(&[]);
let outside = fixtures::tempdir();
std::fs::write(outside.path().join("victim"), b"original").expect("victim");
std::os::unix::fs::symlink(outside.path(), rel.path().join("target")).expect("link");
let cache = fixtures::tempdir();
std::fs::write(cache.path().join("victim"), b"ATTACKER").expect("payload");
let spec = BuildSpec {
command: Some("true".into()),
env: [(
"CARGO_TARGET_DIR".into(),
cache.path().display().to_string(),
)]
.into(),
artifacts: vec![PathBuf::from("target/victim")],
};
let err = run(
"web",
rel.path(),
&spec,
None,
&[],
cache.path(),
TEST_BUILD_BUDGET,
)
.await
.expect_err("a write through a committed symlink must be refused");
assert!(
format!("{err}").contains("outside the release"),
"must say why: {err}"
);
assert_eq!(
std::fs::read_to_string(outside.path().join("victim")).expect("still there"),
"original",
"the file outside the release must be untouched"
);
}
#[tokio::test]
async fn a_symlinked_component_below_target_cannot_carry_a_write_out() {
let rel = fixtures::fixture_release(&[]);
let cache = fixtures::tempdir();
std::fs::create_dir(cache.path().join("sub")).expect("build output dir");
std::fs::write(cache.path().join("sub/out"), b"ARTIFACT").expect("build output");
let outside = fixtures::tempdir();
std::fs::create_dir(rel.path().join("target")).expect("target");
std::os::unix::fs::symlink(outside.path(), rel.path().join("target/sub")).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/sub/out")],
};
let err = run(
"web",
rel.path(),
&spec,
None,
&[],
cache.path(),
TEST_BUILD_BUDGET,
)
.await
.expect_err("a write through a symlinked component must be refused");
assert!(
format!("{err}").contains("outside the release"),
"must say why: {err}"
);
assert!(
!outside.path().join("out").exists(),
"nothing may be written outside the release"
);
}
#[tokio::test]
async fn an_artifact_that_escapes_the_release_is_refused() {
let tree = fixtures::tempdir();
let release = tree.path().join("releases/abc123");
std::fs::create_dir_all(&release).unwrap();
let sentinel = tree.path().join("deploy.toml");
std::fs::write(&sentinel, b"remote = \"https://real.example/repo.git\"").unwrap();
let cache = fixtures::tempdir();
let stolen = cache.path().join("deploy.toml");
std::fs::write(&stolen, b"remote = \"https://attacker.example/evil.git\"").unwrap();
let spec = BuildSpec {
command: Some("true".into()),
env: [(
"CARGO_TARGET_DIR".into(),
cache.path().join("a/b/c").display().to_string(),
)]
.into(),
artifacts: vec![PathBuf::from("target/../../../deploy.toml")],
};
let err = build(&release, &spec)
.await
.expect_err("an escaping artifact must be refused");
assert!(
format!("{err}").contains("outside the release"),
"the refusal must say why, got: {err}"
);
assert_eq!(
std::fs::read_to_string(&sentinel).unwrap(),
"remote = \"https://real.example/repo.git\"",
"the tree's own state file must be untouched"
);
}
#[test]
fn an_escaping_artifact_is_refused_at_parse_time() {
for bad in ["target/../../../deploy.toml", "/etc/passwd"] {
let toml = format!("command = \"true\"\nartifacts = [\"{bad}\"]\n");
let err = toml::from_str::<BuildSpec>(&toml)
.expect_err("an escaping artifact must not parse");
assert!(
format!("{err}").contains(bad),
"the refusal must name the entry, got: {err}"
);
}
}
#[test]
fn debug_redacts_env_values() {
let spec = BuildSpec {
command: Some("make build".into()),
env: [
("REGISTRY_TOKEN".to_string(), "secret".to_string()),
("RUST_LOG".to_string(), "info".to_string()),
]
.into(),
artifacts: vec![],
};
assert_eq!(
format!("{spec:?}"),
"BuildSpec { command: Some(\"make build\"), env: <2 vars>, artifacts: [] }"
);
}
#[tokio::test]
async fn an_absent_command_skips_artifacts_too() {
let rel = fixtures::fixture_release(&[]);
let cache = fixtures::tempdir();
let spec = BuildSpec {
command: None,
env: [(
"CARGO_TARGET_DIR".into(),
cache.path().display().to_string(),
)]
.into(),
artifacts: vec![PathBuf::from("target/release/nothing-built-this")],
};
assert!(build(rel.path(), &spec).await.is_ok());
}
#[tokio::test]
async fn running_as_the_current_user_succeeds() {
let rel = fixtures::fixture_release(&[]);
let spec = BuildSpec {
command: Some("true".into()),
..Default::default()
};
let user = current_username();
run(
"web",
rel.path(),
&spec,
Some(&user),
&[],
fixtures::tempdir().path(),
TEST_BUILD_BUDGET,
)
.await
.expect("dropping to one's own user and group is always permitted");
}
#[tokio::test]
async fn an_unknown_as_user_is_a_config_error() {
let rel = fixtures::fixture_release(&[]);
let spec = BuildSpec {
command: Some("true".into()),
..Default::default()
};
let err = run(
"web",
rel.path(),
&spec,
Some("shep-deploy-test-no-such-user"),
&[],
fixtures::tempdir().path(),
TEST_BUILD_BUDGET,
)
.await
.expect_err("no such user");
assert!(matches!(err, Error::Config(_)));
assert!(err.to_string().contains("does not resolve to a gid"));
}
#[tokio::test]
async fn uid_for_resolves_the_current_users_own_uid() {
let user = current_username();
let uid = uid_for(Path::new("."), &user).await.expect("resolves");
assert_eq!(uid, current_uid());
}
#[tokio::test]
async fn gid_for_resolves_the_current_users_primary_gid() {
let user = current_username();
let gid = gid_for(Path::new("."), &user).await.expect("resolves");
assert_eq!(gid, current_gid());
}
#[tokio::test]
async fn uid_for_reports_a_config_error_for_an_unknown_user() {
let err = uid_for(Path::new("."), "shep-deploy-test-no-such-user")
.await
.expect_err("no such user");
assert!(matches!(err, Error::Config(_)));
assert!(err.to_string().contains("does not resolve to a uid"));
}
#[tokio::test]
async fn gid_for_reports_a_config_error_for_an_unknown_user() {
let err = gid_for(Path::new("."), "shep-deploy-test-no-such-user")
.await
.expect_err("no such user");
assert!(matches!(err, Error::Config(_)));
assert!(err.to_string().contains("does not resolve to a gid"));
}
#[tokio::test]
async fn an_artifact_already_in_the_release_is_left_intact() {
let rel = fixtures::fixture_release(&[]);
let spec = BuildSpec {
command: Some("mkdir -p dist && printf hello > dist/app.js".into()),
artifacts: vec![PathBuf::from("dist/app.js")],
..Default::default()
};
build(rel.path(), &spec).await.expect("builds");
let contents = fs::read_to_string(rel.path().join("dist/app.js")).expect("reads");
assert_eq!(contents, "hello");
}
#[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"
);
}
#[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_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_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}"
);
}
#[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"
);
}
}