use std::collections::BTreeMap;
use std::env;
use std::fmt;
use std::fs;
use std::io;
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::Deserialize;
use tokio::process::Command;
use tokio::time::timeout;
use crate::error::Error;
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.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",
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",
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(|source| Error::Io {
path: release.to_owned(),
source,
})?;
if !output.status.success() {
return Err(Error::Config(format!(
"as_user {user:?} does not resolve to a {kind} on this host (`id {flag} {user}` \
failed)"
)));
}
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> {
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)
}
const fn libc_o_nofollow() -> i32 {
#[cfg(target_os = "linux")]
{
0o400_000
}
#[cfg(target_vendor = "apple")]
{
0x0100
}
#[cfg(not(any(target_os = "linux", target_vendor = "apple")))]
{
compile_error!("O_NOFOLLOW's value is not known for this target")
}
}
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)))
}
fn copy_artifact(
release: &Path,
cache: &Path,
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",
artifact.display()
)));
}
let from = artifact_source(release, env, artifact);
let to = release.join(artifact);
let roots = [
release
.canonicalize()
.unwrap_or_else(|_| release.to_owned()),
cache.canonicalize().unwrap_or_else(|_| cache.to_owned()),
];
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",
artifact.display(),
resolve_deepest(path)
.unwrap_or_else(|| path.clone())
.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",
artifact.display()
)));
}
return Ok(());
}
if let Some(parent) = to.parent() {
fs::create_dir_all(parent).map_err(|source| Error::Io {
path: parent.to_owned(),
source,
})?;
}
let mut source = fs::File::open(&from).map_err(|err| Error::Io {
path: from.clone(),
source: err,
})?;
let opened = source.metadata().map_err(|err| Error::Io {
path: from.clone(),
source: err,
})?;
let resolved = resolve_deepest(&from)
.and_then(|real| fs::metadata(real).ok())
.filter(|named| named.dev() == opened.dev() && named.ino() == opened.ino());
if resolved.is_none() || !lands_within(&roots, &from) {
return Err(Error::Config(format!(
"build.artifacts entry `{}` changed underneath the check; refusing to copy it",
artifact.display()
)));
}
if fs::metadata(&to)
.is_ok_and(|there| there.dev() == opened.dev() && there.ino() == opened.ino())
{
return Ok(());
}
if !lands_within(&roots, &to) {
return Err(Error::Config(format!(
"build.artifacts entry `{}` changed underneath the check; refusing to copy it",
artifact.display()
)));
}
let mut sink = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.custom_flags(libc_o_nofollow())
.open(&to)
.map_err(|err| Error::Io {
path: to.clone(),
source: err,
})?;
io::copy(&mut source, &mut sink).map_err(|err| Error::Io {
path: from.clone(),
source: err,
})?;
let mode = opened.permissions().mode() & 0o777;
sink.set_permissions(fs::Permissions::from_mode(mode))
.map_err(|err| Error::Io {
path: to,
source: err,
})?;
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}");
}
let mut child = Command::new("sh");
child.arg("-c").arg(command);
child.current_dir(release);
child.env_clear();
for (key, value) in BASE_ENV
.iter()
.filter_map(|k| Some((*k, env::var(k).ok()?)))
{
child.env(key, value);
}
for key in passthrough {
if let Ok(value) = env::var(key) {
child.env(key, value);
}
}
child.envs(&spec.env);
if let Some(user) = as_user {
child.gid(gid_for(release, user).await?);
child.uid(uid_for(release, user).await?);
}
child.process_group(0);
let mut child = child.spawn().map_err(|source| Error::Io {
path: release.to_owned(),
source,
})?;
let group = child.id();
let status = match timeout(budget, child.wait()).await {
Ok(waited) => waited.map_err(|source| Error::Io {
path: release.to_owned(),
source,
})?,
Err(_) => {
abandon(&mut child).await;
return Err(Error::BuildTimedOut { after: budget });
}
};
if let Some(pid) = group {
crate::shared::kill_group(pid);
}
if !status.success() {
return Err(Error::Build {
status: status.code(),
});
}
for artifact in &spec.artifacts {
copy_artifact(release, cache, &spec.env, artifact)?;
}
Ok(())
}
async fn abandon(child: &mut tokio::process::Child) {
if let Some(pid) = child.id() {
crate::shared::kill_group(pid);
}
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![],
};
run(
"web",
rel.path(),
&spec,
None,
&[],
tempdir_cache().path(),
TEST_BUILD_BUDGET,
)
.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,
&[],
tempdir_cache().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 = run(
"web",
rel.path(),
&spec,
None,
&[],
tempdir_cache().path(),
TEST_BUILD_BUDGET,
)
.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);
fn tempdir_cache() -> tempfile::TempDir {
fixtures::tempdir()
}
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!(
run(
"web",
rel.path(),
&spec,
None,
&[],
tempdir_cache().path(),
TEST_BUILD_BUDGET
)
.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!(
run(
"web",
rel.path(),
&spec,
None,
&[],
tempdir_cache().path(),
TEST_BUILD_BUDGET
)
.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![],
};
run(
"web",
rel.path(),
&spec,
None,
&[],
tempdir_cache().path(),
TEST_BUILD_BUDGET,
)
.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 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()],
tempdir_cache().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 = tempdir_cache();
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 = run(
"web",
rel.path(),
&spec,
None,
&[],
tempdir_cache().path(),
TEST_BUILD_BUDGET,
)
.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 = run(
"web",
&release,
&spec,
None,
&[],
tempdir_cache().path(),
TEST_BUILD_BUDGET,
)
.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!(
run(
"web",
rel.path(),
&spec,
None,
&[],
tempdir_cache().path(),
TEST_BUILD_BUDGET
)
.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),
&[],
tempdir_cache().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"),
&[],
tempdir_cache().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()
};
run(
"web",
rel.path(),
&spec,
None,
&[],
tempdir_cache().path(),
TEST_BUILD_BUDGET,
)
.await
.expect("builds");
let contents = fs::read_to_string(rel.path().join("dist/app.js")).expect("reads");
assert_eq!(contents, "hello");
}
}