use std::path::{Path, PathBuf};
#[cfg(doc)]
use super::contained_artifacts;
pub(super) 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;
}
}
}
pub(super) fn lands_within(roots: &[PathBuf], candidate: &Path) -> bool {
resolve_deepest(candidate).is_some_and(|real| roots.iter().any(|root| real.starts_with(root)))
}
pub(super) 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()),
]
}
#[cfg(test)]
mod tests {
use super::super::test_support::{TEST_BUILD_BUDGET, build};
use super::super::{BuildSpec, run};
use crate::fixtures;
use std::path::PathBuf;
#[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"
);
}
}