use crate::util::{check_status, run_with_cancel};
use async_trait::async_trait;
use repolith_core::action::Action;
use repolith_core::types::{ActionId, BuildError, BuildOutput, Ctx, Sha256};
use sha2::{Digest, Sha256 as ShaHasher};
use std::path::{Path, PathBuf};
use tokio::process::Command;
pub struct DockerBuild {
pub id: ActionId,
pub tag: String,
pub dockerfile: Option<PathBuf>,
pub context: Option<PathBuf>,
pub node_path: PathBuf,
pub deps: Vec<ActionId>,
}
pub(crate) fn contain_within(base: &Path, relative: &Path) -> Result<PathBuf, BuildError> {
let base_canon = base
.canonicalize()
.map_err(|e| BuildError::Io(format!("canonicalize base {}: {e}", base.display())))?;
let joined = base_canon.join(relative);
let canon = joined
.canonicalize()
.map_err(|e| BuildError::Io(format!("canonicalize {}: {e}", joined.display())))?;
if !canon.starts_with(&base_canon) {
return Err(BuildError::Io(format!(
"path {} escapes the node checkout {} (symlink traversal?)",
canon.display(),
base_canon.display()
)));
}
Ok(canon)
}
impl DockerBuild {
fn resolved_paths(&self) -> Result<(PathBuf, PathBuf), BuildError> {
let context_rel = self.context.as_deref().unwrap_or(Path::new("."));
let context = contain_within(&self.node_path, context_rel)?;
let dockerfile_rel = self
.dockerfile
.as_deref()
.unwrap_or(Path::new("Dockerfile"));
let dockerfile = contain_within(&self.node_path, &context_rel.join(dockerfile_rel))?;
Ok((context, dockerfile))
}
}
#[async_trait]
impl Action for DockerBuild {
fn id(&self) -> ActionId {
self.id.clone()
}
fn deps(&self) -> Vec<ActionId> {
self.deps.clone()
}
async fn input_hash(&self, ctx: &Ctx) -> Result<Sha256, BuildError> {
let (context, dockerfile) = self.resolved_paths()?;
let mut h = ShaHasher::new();
h.update(b"docker:tag:");
h.update(self.tag.as_bytes());
let df_bytes = tokio::fs::read(&dockerfile).await.map_err(|e| {
BuildError::Io(format!("read dockerfile {}: {e}", dockerfile.display()))
})?;
h.update(b":dockerfile:");
h.update(&df_bytes);
let mut head = Command::new("git");
head.args(["-C"]).arg(&context).args(["rev-parse", "HEAD"]);
let head_out = run_with_cancel(head, &ctx.cancel).await?;
h.update(b":checkout:");
if head_out.status.success() {
h.update(&head_out.stdout);
} else {
h.update(b"no-git");
}
let mut ver = Command::new("docker");
ver.arg("--version");
let v = run_with_cancel(ver, &ctx.cancel).await?;
if !v.status.success() {
return Err(BuildError::UpstreamUnreachable(
"docker --version failed — is the docker CLI installed and on PATH?".to_string(),
));
}
h.update(b":docker:");
h.update(&v.stdout);
Ok(Sha256(h.finalize().into()))
}
async fn execute(&self, ctx: &Ctx) -> Result<BuildOutput, BuildError> {
let (context, dockerfile) = self.resolved_paths()?;
let context_str = context.to_str().ok_or_else(|| {
BuildError::Io(format!("non-utf8 context path: {}", context.display()))
})?;
let dockerfile_str = dockerfile.to_str().ok_or_else(|| {
BuildError::Io(format!(
"non-utf8 dockerfile path: {}",
dockerfile.display()
))
})?;
let mut cmd = Command::new("docker");
cmd.args([
"build",
"-f",
dockerfile_str,
"-t",
&self.tag,
"--",
context_str,
]);
check_status(&run_with_cancel(cmd, &ctx.cancel).await?)?;
let mut inspect = Command::new("docker");
inspect.args(["image", "inspect", "--format", "{{.Id}}", "--", &self.tag]);
let out = run_with_cancel(inspect, &ctx.cancel).await?;
if !out.status.success() {
return Err(BuildError::Io(format!(
"docker image inspect {} failed after build: {}",
self.tag,
String::from_utf8_lossy(&out.stderr).trim()
)));
}
let image_id = String::from_utf8_lossy(&out.stdout).trim().to_string();
if image_id.is_empty() {
return Err(BuildError::Io(format!(
"docker image inspect {} returned an empty id",
self.tag
)));
}
let mut h = ShaHasher::new();
h.update(image_id.as_bytes());
Ok(BuildOutput {
output_hash: Sha256(h.finalize().into()),
stdout: format!("built {} -> {image_id}", self.tag),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn contain_within_accepts_inner_path() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("sub")).unwrap();
let got = contain_within(dir.path(), Path::new("sub")).unwrap();
assert!(got.ends_with("sub"));
}
#[test]
fn contain_within_rejects_missing_path() {
let dir = tempfile::tempdir().unwrap();
assert!(contain_within(dir.path(), Path::new("absent")).is_err());
}
#[cfg(unix)]
#[test]
fn contain_within_rejects_symlink_escape() {
let outside = tempfile::tempdir().unwrap();
let dir = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(outside.path(), dir.path().join("evil")).unwrap();
let err = contain_within(dir.path(), Path::new("evil")).unwrap_err();
assert!(err.to_string().contains("escapes"), "got: {err}");
}
#[cfg(unix)]
#[test]
fn contain_within_rejects_symlinked_file_escape() {
let outside = tempfile::tempdir().unwrap();
let secret = outside.path().join("secret");
std::fs::write(&secret, b"x").unwrap();
let dir = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(&secret, dir.path().join("Dockerfile")).unwrap();
let err = contain_within(dir.path(), Path::new("Dockerfile")).unwrap_err();
assert!(err.to_string().contains("escapes"), "got: {err}");
}
}