use crate::core::task::hash_outputs_in;
use crate::core::types::Resource;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkipReason {
NoCommand,
NoOutputArtifacts,
NoRecordedHash,
WorkingDirUnavailable,
}
impl SkipReason {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::NoCommand => "no-command",
Self::NoOutputArtifacts => "no-output-artifacts",
Self::NoRecordedHash => "no-recorded-hash",
Self::WorkingDirUnavailable => "working-dir-unavailable",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verdict {
Reproduced,
Diverged {
recorded: String,
regenerated: Option<String>,
},
CommandFailed {
status: String,
},
UndeclaredInput {
hermetic: String,
},
Skipped(SkipReason),
}
impl Verdict {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Reproduced => "reproduced",
Self::Diverged { .. } => "diverged",
Self::CommandFailed { .. } => "command-failed",
Self::UndeclaredInput { .. } => "undeclared-input",
Self::Skipped(_) => "skipped",
}
}
#[must_use]
pub fn is_failure(&self) -> bool {
matches!(
self,
Self::Diverged { .. } | Self::CommandFailed { .. } | Self::UndeclaredInput { .. }
)
}
}
#[derive(Debug, Clone)]
pub struct VerifyOutcome {
pub resource_id: String,
pub verdict: Verdict,
}
#[must_use]
pub fn verifiability(resource: &Resource, recorded_hash: Option<&str>) -> Option<SkipReason> {
if resource.command.is_none() {
return Some(SkipReason::NoCommand);
}
if resource.output_artifacts.is_empty() {
return Some(SkipReason::NoOutputArtifacts);
}
if recorded_hash.is_none() {
return Some(SkipReason::NoRecordedHash);
}
match resource.working_dir.as_deref() {
Some(d) if Path::new(d).is_dir() => None,
_ => Some(SkipReason::WorkingDirUnavailable),
}
}
fn copy_tree_excluding(src: &Path, dst: &Path, exclude: &[PathBuf]) -> Result<(), String> {
std::fs::create_dir_all(dst).map_err(|e| format!("scratch mkdir {}: {e}", dst.display()))?;
let entries = std::fs::read_dir(src).map_err(|e| format!("read_dir {}: {e}", src.display()))?;
for entry in entries.filter_map(Result::ok) {
let from = entry.path();
if from.file_name().is_some_and(|n| n == ".git") {
continue;
}
if exclude.iter().any(|e| e == &from) {
continue;
}
let to = dst.join(entry.file_name());
if from.is_dir() {
copy_tree_excluding(&from, &to, exclude)?;
} else {
std::fs::copy(&from, &to)
.map_err(|e| format!("copy {} -> {}: {e}", from.display(), to.display()))?;
}
}
Ok(())
}
fn artifact_paths(resource: &Resource, base: &Path) -> Vec<PathBuf> {
resource
.output_artifacts
.iter()
.map(|a| {
let p = Path::new(a);
if p.is_absolute() {
p.to_path_buf()
} else {
base.join(p)
}
})
.collect()
}
pub fn verify_resource(
resource_id: &str,
resource: &Resource,
recorded_hash: Option<&str>,
scratch_root: &Path,
) -> VerifyOutcome {
let id = resource_id.to_string();
if let Some(reason) = verifiability(resource, recorded_hash) {
return VerifyOutcome {
resource_id: id,
verdict: Verdict::Skipped(reason),
};
}
let command = resource.command.as_deref().unwrap_or_default();
let recorded = recorded_hash.unwrap_or_default();
let work = PathBuf::from(resource.working_dir.as_deref().unwrap_or_default());
let exclude = artifact_paths(resource, &work);
if let Err(e) = copy_tree_excluding(&work, scratch_root, &exclude) {
return VerifyOutcome {
resource_id: id,
verdict: Verdict::CommandFailed { status: e },
};
}
let out = std::process::Command::new("bash")
.arg("-c")
.arg(command)
.current_dir(scratch_root)
.output();
let out = match out {
Ok(o) => o,
Err(e) => {
return VerifyOutcome {
resource_id: id,
verdict: Verdict::CommandFailed {
status: format!("spawn: {e}"),
},
}
}
};
if !out.status.success() {
return VerifyOutcome {
resource_id: id,
verdict: Verdict::CommandFailed {
status: format!(
"exit {}: {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
),
},
};
}
let regenerated = hash_outputs_in(&resource.output_artifacts, scratch_root).unwrap_or(None);
let verdict = if regenerated.as_deref() == Some(recorded) {
Verdict::Reproduced
} else {
Verdict::Diverged {
recorded: recorded.to_string(),
regenerated,
}
};
VerifyOutcome {
resource_id: id,
verdict,
}
}
#[cfg(test)]
#[path = "tests_verify.rs"]
mod tests_verify;
pub fn verify_hermetic(
resource_id: &str,
resource: &Resource,
recorded_hash: Option<&str>,
scratch_root: &Path,
) -> VerifyOutcome {
let full = verify_resource(
resource_id,
resource,
recorded_hash,
&scratch_root.join("full"),
);
if full.verdict != Verdict::Reproduced {
return full;
}
let hermetic_root = scratch_root.join("declared");
match run_from_declared_inputs(resource, recorded_hash, &hermetic_root) {
Ok(true) => full,
Ok(false) => VerifyOutcome {
resource_id: resource_id.to_string(),
verdict: Verdict::UndeclaredInput {
hermetic: "outputs differ when only task_inputs are present".to_string(),
},
},
Err(e) => VerifyOutcome {
resource_id: resource_id.to_string(),
verdict: Verdict::UndeclaredInput { hermetic: e },
},
}
}
fn run_from_declared_inputs(
resource: &Resource,
recorded_hash: Option<&str>,
root: &Path,
) -> Result<bool, String> {
let work = PathBuf::from(resource.working_dir.as_deref().unwrap_or_default());
std::fs::create_dir_all(root).map_err(|e| format!("scratch mkdir: {e}"))?;
for pattern in &resource.task_inputs {
for matched in crate::core::task::expand_glob(pattern, &work)? {
let src = Path::new(&matched);
let rel = src.strip_prefix(&work).unwrap_or(src);
let dst = root.join(rel);
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("scratch mkdir: {e}"))?;
}
std::fs::copy(src, &dst)
.map_err(|e| format!("copy declared input {}: {e}", src.display()))?;
}
}
let out = std::process::Command::new("bash")
.arg("-c")
.arg(resource.command.as_deref().unwrap_or("true"))
.current_dir(root)
.output()
.map_err(|e| format!("spawn: {e}"))?;
if !out.status.success() {
return Err(format!(
"recipe fails with only task_inputs present ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
));
}
let regenerated = hash_outputs_in(&resource.output_artifacts, root).unwrap_or(None);
Ok(regenerated.as_deref() == recorded_hash)
}