use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use owo_colors::OwoColorize;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::ingress_meta::FetchMeta;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModifiedAfterConfig {
pub path: String,
pub period: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FreshConfig {
pub path: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub contents: bool,
}
fn is_false(b: &bool) -> bool {
!*b
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Precondition {
ModifiedAfter { modified_after: ModifiedAfterConfig },
Fresh { fresh: FreshConfig },
Command { command: String },
Tool { tool: ToolConfig },
}
impl Precondition {
pub(crate) fn evaluate(
&self,
manifest_dir: &Path,
step_name: &str,
env: &HashMap<String, String>,
) -> Result<bool> {
match self {
Precondition::ModifiedAfter { modified_after } => {
let period_duration =
humantime::parse_duration(&modified_after.period).map_err(|e| {
Error::ManifestValidation(format!(
"invalid duration '{}': {}",
modified_after.period, e
))
})?;
let file_path = manifest_dir.join(&modified_after.path);
let fresh = (|| -> Option<bool> {
let metadata = std::fs::metadata(&file_path).ok()?;
let modified = metadata.modified().ok()?;
let elapsed = SystemTime::now().duration_since(modified).ok()?;
Some(elapsed < period_duration)
})();
Ok(fresh.unwrap_or(false))
}
Precondition::Fresh { fresh } => {
let file_path = manifest_dir.join(&fresh.path);
if !file_path.exists() {
return Ok(false);
}
let Some(meta) = crate::ingress_meta::read(&file_path) else {
return Ok(false);
};
if let Some(refusal) = local_copy_refusal(&file_path, &meta) {
let remedy = discard_sidecar(&file_path);
eprintln!("{} {}{}", "warning:".yellow(), refusal, remedy);
return Ok(false);
}
#[cfg(feature = "http-fetch")]
{
let mut req =
ureq::head(&meta.url).set("User-Agent", crate::ingress_meta::DEFAULT_UA);
for (k, v) in &meta.request_headers {
req = req.set(k, v);
}
if let Some(ref etag) = meta.etag {
req = req.set("If-None-Match", etag);
}
if let Some(ref lm) = meta.last_modified {
req = req.set("If-Modified-Since", lm);
}
let fresh = match req.call() {
Ok(resp) if resp.status() == 304 => true,
Ok(resp) => {
let etag_match = matches!(
(&meta.etag, resp.header("ETag")),
(Some(a), Some(b)) if a == b
);
let lm_match = matches!(
(&meta.last_modified, resp.header("Last-Modified")),
(Some(a), Some(b)) if a == b
);
etag_match || lm_match
}
Err(ureq::Error::Status(304, _)) => true,
Err(_) => false,
};
Ok(fresh)
}
#[cfg(not(feature = "http-fetch"))]
{
Ok(false)
}
}
Precondition::Command { command: cmd } => {
let output = std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.current_dir(manifest_dir)
.envs(env)
.output();
match output {
Ok(o) => Ok(o.status.success()),
Err(e) => Err(Error::Precondition {
step: step_name.to_string(),
command: cmd.clone(),
detail: e.to_string(),
}),
}
}
Precondition::Tool { tool } => {
let observed = tool.observe(manifest_dir, step_name, env)?;
let recorded = recorded_identity(manifest_dir, step_name, &tool.stamp_key());
Ok(recorded.as_deref() == Some(observed.as_str()))
}
}
}
pub fn validate(&self) -> Result<()> {
match self {
Precondition::ModifiedAfter { modified_after } => {
if modified_after.path.is_empty() {
return Err(Error::ManifestValidation(
"modified_after precondition: 'path' cannot be empty".to_string(),
));
}
if modified_after.period.is_empty() {
return Err(Error::ManifestValidation(
"modified_after precondition: 'period' cannot be empty".to_string(),
));
}
humantime::parse_duration(&modified_after.period).map_err(|e| {
Error::ManifestValidation(format!(
"modified_after precondition: invalid duration '{}': {}",
modified_after.period, e
))
})?;
Ok(())
}
Precondition::Fresh { fresh } => {
if fresh.path.is_empty() {
return Err(Error::ManifestValidation(
"fresh precondition: 'path' cannot be empty".to_string(),
));
}
Ok(())
}
Precondition::Command { command: cmd } => {
if cmd.is_empty() {
return Err(Error::ManifestValidation(
"command precondition: command cannot be empty".to_string(),
));
}
Ok(())
}
Precondition::Tool { tool } => tool.validate(),
}
}
}
impl ToolConfig {
fn validate(&self) -> Result<()> {
let mut resolvers: Vec<&str> = Vec::new();
if non_empty(&self.name).is_some() {
resolvers.push("name");
}
if non_empty(&self.path).is_some() {
resolvers.push("path");
}
if non_empty(&self.env).is_some() {
resolvers.push("env");
}
match resolvers.len() {
0 => {
return Err(Error::ManifestValidation(
"tool precondition: names no tool — give exactly one of `name:` (an \
executable on PATH), `path:` (a file) or `env:` (an environment \
variable holding a path)"
.to_string(),
));
}
1 => {}
_ => {
return Err(Error::ManifestValidation(format!(
"tool precondition: names the tool {} ways ({}) — give exactly one of \
`name:`, `path:` or `env:`",
resolvers.len(),
resolvers.join(", ")
)));
}
}
let has_version = non_empty(&self.version).is_some();
match (has_version, self.contents) {
(false, false) => Err(Error::ManifestValidation(
"tool precondition: says nothing about what identifies the tool — give \
`version:` (a shell command that reports it, with `$ARC_TOOL` bound to the \
resolved path) or `contents: true` (the sha256 of the resolved file)"
.to_string(),
)),
(true, true) => Err(Error::ManifestValidation(
"tool precondition: `version:` and `contents: true` are two identities for \
one tool — give one"
.to_string(),
)),
_ => Ok(()),
}
}
fn declared(&self) -> String {
if let Some(name) = non_empty(&self.name) {
format!("`{name}` on PATH")
} else if let Some(path) = non_empty(&self.path) {
format!("path `{path}`")
} else if let Some(var) = non_empty(&self.env) {
format!("${var}")
} else {
"(no tool named)".to_string()
}
}
fn stamp_key(&self) -> String {
let identity = if self.contents {
"contents".to_string()
} else {
format!("version:{}", non_empty(&self.version).unwrap_or_default())
};
format!("{}|{}", self.declared(), identity)
}
fn resolve(
&self,
manifest_dir: &Path,
env: &HashMap<String, String>,
) -> std::result::Result<PathBuf, String> {
if let Some(name) = non_empty(&self.name) {
let path_var = env
.get("PATH")
.cloned()
.or_else(|| std::env::var("PATH").ok())
.unwrap_or_default();
for entry in std::env::split_paths(&path_var) {
let candidate = entry.join(name);
if is_executable_file(&candidate) {
return Ok(candidate);
}
}
return Err(format!(
"no executable `{name}` on PATH ({})",
if path_var.is_empty() {
"PATH is unset".to_string()
} else {
path_var
}
));
}
let (declared_path, provenance) = if let Some(path) = non_empty(&self.path) {
(path.to_string(), String::new())
} else if let Some(var) = non_empty(&self.env) {
let raw = env
.get(var)
.cloned()
.or_else(|| std::env::var(var).ok())
.unwrap_or_default();
if raw.trim().is_empty() {
return Err(format!(
"environment variable {var} is unset or empty, so there is no path to \
identify"
));
}
(raw.trim().to_string(), format!("{var}="))
} else {
return Err("no tool named".to_string());
};
let resolved = manifest_dir.join(&declared_path);
if !resolved.exists() {
return Err(format!(
"{provenance}{declared_path} resolves to {}, which does not exist",
resolved.display()
));
}
Ok(resolved)
}
fn observe(
&self,
manifest_dir: &Path,
step_name: &str,
env: &HashMap<String, String>,
) -> Result<String> {
let resolved = self
.resolve(manifest_dir, env)
.map_err(|detail| self.refusal(step_name, detail))?;
if self.contents {
let hex = crate::fetch_cache::hash_file(&resolved).map_err(|e| {
self.refusal(
step_name,
format!("{} could not be read to hash it: {e}", resolved.display()),
)
})?;
return Ok(format!("sha256:{hex}"));
}
let command = non_empty(&self.version).unwrap_or_default();
let output = std::process::Command::new("sh")
.arg("-c")
.arg(command)
.current_dir(manifest_dir)
.envs(env)
.env("ARC_TOOL", &resolved)
.output()
.map_err(|e| {
self.refusal(
step_name,
format!(
"`{command}` (ARC_TOOL={}) failed to execute: {e}",
resolved.display()
),
)
})?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if !output.status.success() {
return Err(self.refusal(
step_name,
format!(
"`{command}` (ARC_TOOL={}) exited {}: {}",
resolved.display(),
output
.status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "on a signal".to_string()),
if stderr.is_empty() { &stdout } else { &stderr }
),
));
}
let reported = if stdout.is_empty() { stderr } else { stdout };
if reported.is_empty() {
return Err(self.refusal(
step_name,
format!(
"`{command}` (ARC_TOOL={}) reported no version on stdout or stderr",
resolved.display()
),
));
}
Ok(format!("version:{reported}"))
}
fn refusal(&self, step_name: &str, detail: String) -> Error {
Error::ToolPrecondition {
step: step_name.to_string(),
tool: self.declared(),
detail,
}
}
}
fn non_empty(field: &Option<String>) -> Option<&str> {
field.as_deref().map(str::trim).filter(|s| !s.is_empty())
}
#[cfg(unix)]
fn is_executable_file(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_executable_file(path: &Path) -> bool {
path.is_file()
}
fn stamp_store_path(manifest_dir: &Path) -> PathBuf {
manifest_dir
.join("build")
.join(".arcform")
.join("tools.json")
}
type ToolStamps = BTreeMap<String, BTreeMap<String, String>>;
fn read_stamps(manifest_dir: &Path) -> ToolStamps {
std::fs::read_to_string(stamp_store_path(manifest_dir))
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_default()
}
fn recorded_identity(manifest_dir: &Path, step_name: &str, key: &str) -> Option<String> {
read_stamps(manifest_dir)
.get(step_name)
.and_then(|step| step.get(key))
.cloned()
}
pub(crate) fn record_all(
preconditions: &[Precondition],
manifest_dir: &Path,
step_name: &str,
env: &HashMap<String, String>,
) {
let tools: Vec<&ToolConfig> = preconditions
.iter()
.filter_map(|p| match p {
Precondition::Tool { tool } => Some(tool),
_ => None,
})
.collect();
if tools.is_empty() {
return;
}
let mut stamps = read_stamps(manifest_dir);
{
let step_stamps = stamps.entry(step_name.to_string()).or_default();
for tool in tools {
match tool.observe(manifest_dir, step_name, env) {
Ok(identity) => {
step_stamps.insert(tool.stamp_key(), identity);
}
Err(e) => {
step_stamps.remove(&tool.stamp_key());
eprintln!(
"{} {e} — the step ran, but its tool identity was not recorded, so it \
will run again",
"warning:".yellow()
);
}
}
}
}
let path = stamp_store_path(manifest_dir);
let written = path
.parent()
.map(std::fs::create_dir_all)
.unwrap_or(Ok(()))
.and_then(|()| serde_json::to_string_pretty(&stamps).map_err(std::io::Error::other))
.and_then(|json| std::fs::write(&path, json + "\n"));
if let Err(e) = written {
eprintln!(
"{} could not record tool identities for step '{step_name}' at {} ({e}) — the \
step will run again",
"warning:".yellow(),
path.display()
);
}
}
fn local_copy_refusal(file_path: &Path, meta: &FetchMeta) -> Option<String> {
let recorded = meta.sha256.trim();
if recorded.is_empty() {
return Some(format!(
"fresh: local copy {} cannot be verified: its .arcmeta records no sha256 \
— the remote was not consulted",
file_path.display()
));
}
let on_disk = match crate::fetch_cache::hash_file(file_path) {
Ok(hex) => hex,
Err(e) => {
return Some(format!(
"fresh: local copy {} could not be read to verify it ({e}) \
— the remote was not consulted",
file_path.display()
));
}
};
if on_disk.eq_ignore_ascii_case(recorded) {
return None;
}
Some(format!(
"fresh: local copy {} does not match the sha256 its fetch recorded \
(recorded {recorded}, on disk {on_disk}) — the remote was not consulted",
file_path.display()
))
}
fn discard_sidecar(file_path: &Path) -> String {
match crate::ingress_meta::remove(file_path) {
Ok(()) => "; its .arcmeta is discarded, so the re-fetch replaces these bytes".to_string(),
Err(e) => format!(
"; its .arcmeta could not be discarded ({e}), \
so the re-fetch may be answered with a 304 and leave these bytes in place"
),
}
}
pub(crate) fn evaluate_all(
preconditions: &[Precondition],
manifest_dir: &Path,
step_name: &str,
env: &HashMap<String, String>,
) -> Result<bool> {
for p in preconditions {
if !p.evaluate(manifest_dir, step_name, env)? {
return Ok(false);
}
}
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::time::{Duration, SystemTime};
#[test]
fn test_pre_enum_variants() {
let ma = Precondition::ModifiedAfter {
modified_after: ModifiedAfterConfig {
path: "data/file.json".to_string(),
period: "24h".to_string(),
},
};
let cmd = Precondition::Command {
command: "true".to_string(),
};
assert!(matches!(ma, Precondition::ModifiedAfter { .. }));
assert!(matches!(cmd, Precondition::Command { .. }));
}
#[test]
fn test_pre_serde_roundtrip() {
let preconditions = vec![
Precondition::ModifiedAfter {
modified_after: ModifiedAfterConfig {
path: "data/cask.json".to_string(),
period: "24h".to_string(),
},
},
Precondition::Command {
command: "test $SKIP".to_string(),
},
];
let yaml = serde_yaml::to_string(&preconditions).unwrap();
let parsed: Vec<Precondition> = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(parsed.len(), 2);
assert!(matches!(parsed[0], Precondition::ModifiedAfter { .. }));
assert!(matches!(parsed[1], Precondition::Command { .. }));
}
fn ma(path: &str, period: &str) -> Precondition {
Precondition::ModifiedAfter {
modified_after: ModifiedAfterConfig {
path: path.to_string(),
period: period.to_string(),
},
}
}
fn cmd(command: &str) -> Precondition {
Precondition::Command {
command: command.to_string(),
}
}
fn empty_env() -> HashMap<String, String> {
HashMap::new()
}
#[test]
fn test_pre_modified_after_fresh() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("data.json"), "{}").unwrap();
assert!(
ma("data.json", "1h")
.evaluate(dir.path(), "test-step", &empty_env())
.unwrap()
);
}
#[test]
fn test_pre_modified_after_stale() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("data.json");
fs::write(&file_path, "{}").unwrap();
let old_time = SystemTime::now() - Duration::from_secs(48 * 3600);
let ft = filetime::FileTime::from_system_time(old_time);
filetime::set_file_mtime(&file_path, ft).unwrap();
assert!(
!ma("data.json", "24h")
.evaluate(dir.path(), "test-step", &empty_env())
.unwrap()
);
}
#[test]
fn test_pre_missing_file_is_stale() {
let dir = tempfile::tempdir().unwrap();
assert!(
!ma("nonexistent.json", "24h")
.evaluate(dir.path(), "test-step", &empty_env())
.unwrap()
);
}
#[test]
fn test_pre_command_exit_zero_fresh() {
let dir = tempfile::tempdir().unwrap();
assert!(
cmd("true")
.evaluate(dir.path(), "test-step", &empty_env())
.unwrap()
);
}
#[test]
fn test_pre_command_nonzero_stale() {
let dir = tempfile::tempdir().unwrap();
assert!(
!cmd("false")
.evaluate(dir.path(), "test-step", &empty_env())
.unwrap()
);
}
#[test]
fn test_command_precondition_receives_env() {
let dir = tempfile::tempdir().unwrap();
let mut env = HashMap::new();
env.insert("ARC_PARAM_SEARCH_TERM".to_string(), "hello".to_string());
assert!(
cmd("test -n \"$ARC_PARAM_SEARCH_TERM\"")
.evaluate(dir.path(), "test-step", &env)
.unwrap()
);
assert!(
!cmd("test -n \"$ARC_PARAM_SEARCH_TERM\"")
.evaluate(dir.path(), "test-step", &empty_env())
.unwrap()
);
}
#[test]
fn test_pre_and_semantics_all_fresh() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.json"), "{}").unwrap();
fs::write(dir.path().join("b.json"), "{}").unwrap();
let preconditions = vec![ma("a.json", "1h"), ma("b.json", "1h")];
assert!(evaluate_all(&preconditions, dir.path(), "test-step", &empty_env()).unwrap());
}
#[test]
fn test_pre_and_semantics_one_stale() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("fresh.json"), "{}").unwrap();
let preconditions = vec![ma("fresh.json", "1h"), ma("stale.json", "1h")];
assert!(!evaluate_all(&preconditions, dir.path(), "test-step", &empty_env()).unwrap());
}
#[test]
fn test_pre_command_nonzero_is_stale() {
let dir = tempfile::tempdir().unwrap();
assert!(
!cmd("exit 1")
.evaluate(dir.path(), "test-step", &empty_env())
.unwrap()
);
}
#[test]
fn test_pre_duration_parsing() {
for duration_str in &["24h", "60m", "7d", "1h30m", "30s", "2d12h"] {
assert!(
humantime::parse_duration(duration_str).is_ok(),
"Failed: {}",
duration_str
);
}
}
#[test]
fn test_pre_validate_empty_path() {
assert!(ma("", "24h").validate().is_err());
}
#[test]
fn test_pre_validate_empty_period() {
assert!(ma("file.json", "").validate().is_err());
}
#[test]
fn test_pre_validate_invalid_duration() {
assert!(ma("file.json", "banana").validate().is_err());
}
#[test]
fn test_pre_validate_empty_command() {
assert!(cmd("").validate().is_err());
}
#[test]
fn test_pre_validate_valid() {
assert!(ma("data.json", "24h").validate().is_ok());
assert!(cmd("test -f output.csv").validate().is_ok());
}
fn fresh(path: &str) -> Precondition {
Precondition::Fresh {
fresh: FreshConfig {
path: path.to_string(),
},
}
}
#[test]
fn test_fresh_missing_artifact_is_stale() {
let dir = tempfile::tempdir().unwrap();
assert!(
!fresh("build/edgar.parquet")
.evaluate(dir.path(), "test-step", &empty_env())
.unwrap()
);
}
#[test]
fn test_fresh_missing_sidecar_is_stale() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("data.parquet"), b"bytes").unwrap();
assert!(
!fresh("data.parquet")
.evaluate(dir.path(), "test-step", &empty_env())
.unwrap()
);
}
#[cfg(feature = "http-fetch")]
fn probe_server_304() -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let hits = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&hits);
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { break };
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let mut buf = [0u8; 2048];
let _ = stream.read(&mut buf);
let _ = stream.write_all(b"HTTP/1.1 304 Not Modified\r\nConnection: close\r\n\r\n");
let _ = stream.flush();
}
});
(format!("http://{addr}/artifact"), hits)
}
#[cfg(feature = "http-fetch")]
fn probe_hits(hits: &std::sync::atomic::AtomicUsize) -> usize {
hits.load(std::sync::atomic::Ordering::SeqCst)
}
fn fetched_then_replaced(dir: &Path, url: &str, fetched: &[u8], on_disk: &[u8]) {
let out = dir.join("data.parquet");
fs::write(&out, fetched).unwrap();
crate::ingress_meta::write(
&out,
&crate::ingress_meta::FetchMeta {
url: url.to_string(),
etag: Some("\"v1\"".to_string()),
sha256: crate::state::content_hash(fetched),
..Default::default()
},
)
.unwrap();
fs::write(&out, on_disk).unwrap();
}
#[cfg(feature = "http-fetch")]
#[test]
fn test_fresh_intact_artifact_is_fresh_via_the_probe() {
let dir = tempfile::tempdir().unwrap();
let (url, hits) = probe_server_304();
let bytes = b"cik,lei\n0000320193,HWUPKR0MPOU8FGXBT394\n";
fetched_then_replaced(dir.path(), &url, bytes, bytes);
assert!(
fresh("data.parquet")
.evaluate(dir.path(), "test-step", &empty_env())
.unwrap()
);
assert_eq!(probe_hits(&hits), 1, "the remote was probed");
}
#[cfg(feature = "http-fetch")]
#[test]
fn test_fresh_corrupted_artifact_is_stale_without_probing() {
let dir = tempfile::tempdir().unwrap();
let (url, hits) = probe_server_304();
fetched_then_replaced(
dir.path(),
&url,
b"cik,lei\n0000320193,HWUPKR0MPOU8FGXBT394\n",
b"cik,lei\n0000320193,HWUPKR0MPOU8FGXBT395\n",
);
assert!(
!fresh("data.parquet")
.evaluate(dir.path(), "test-step", &empty_env())
.unwrap(),
"a corrupted artifact must not pass the gate"
);
assert_eq!(probe_hits(&hits), 0, "the remote must not be consulted");
}
#[cfg(feature = "http-fetch")]
#[test]
fn test_fresh_truncated_artifact_is_stale_without_probing() {
let dir = tempfile::tempdir().unwrap();
let (url, hits) = probe_server_304();
fetched_then_replaced(
dir.path(),
&url,
b"cik,lei\n0000320193,HWUPKR0MPOU8FGXBT394\n",
b"cik,lei\n0000320193,HWU",
);
assert!(
!fresh("data.parquet")
.evaluate(dir.path(), "test-step", &empty_env())
.unwrap(),
"a truncated artifact must not pass the gate"
);
assert_eq!(probe_hits(&hits), 0, "the remote must not be consulted");
}
#[cfg(feature = "http-fetch")]
#[test]
fn test_fresh_sidecar_without_sha256_is_stale_without_probing() {
let dir = tempfile::tempdir().unwrap();
let (url, hits) = probe_server_304();
let out = dir.path().join("data.parquet");
fs::write(&out, b"bytes").unwrap();
crate::ingress_meta::write(
&out,
&crate::ingress_meta::FetchMeta {
url,
etag: Some("\"v1\"".to_string()),
..Default::default()
},
)
.unwrap();
assert!(
!fresh("data.parquet")
.evaluate(dir.path(), "test-step", &empty_env())
.unwrap()
);
assert_eq!(probe_hits(&hits), 0, "the remote must not be consulted");
}
#[cfg(feature = "http-fetch")]
fn etag_server(body: &'static [u8]) -> (String, std::sync::Arc<std::sync::Mutex<Vec<bool>>>) {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let requests: Arc<Mutex<Vec<bool>>> = Arc::new(Mutex::new(Vec::new()));
let log = Arc::clone(&requests);
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { break };
let mut buf = [0u8; 2048];
let n = stream.read(&mut buf).unwrap_or(0);
let head = String::from_utf8_lossy(&buf[..n]).to_ascii_lowercase();
let conditional =
head.contains("if-none-match") || head.contains("if-modified-since");
log.lock().unwrap().push(conditional);
if conditional {
let _ = stream.write_all(
b"HTTP/1.1 304 Not Modified\r\nETag: \"v1\"\r\nConnection: close\r\n\r\n",
);
} else {
let _ = stream.write_all(
format!(
"HTTP/1.1 200 OK\r\nETag: \"v1\"\r\nContent-Length: {}\r\n\
Connection: close\r\n\r\n",
body.len()
)
.as_bytes(),
);
let _ = stream.write_all(body);
}
let _ = stream.flush();
}
});
(format!("http://{addr}/artifact"), requests)
}
#[cfg(feature = "http-fetch")]
fn http_fetch_into(dir: &Path, url: &str) {
http_fetch_into_with_cache(dir, url, None);
}
#[cfg(feature = "http-fetch")]
fn http_fetch_into_with_cache(
dir: &Path,
url: &str,
cache: Option<&crate::fetch_cache::FetchCache>,
) {
use crate::operator::OpContext;
let with: serde_yaml::Value =
serde_yaml::from_str(&format!("url: {url}\nout: data.parquet\n")).unwrap();
let env = empty_env();
let db = dir.join("pipeline.duckdb");
crate::operator::resolve("http_fetch")
.expect("http_fetch is in the catalog")
.run(
&with,
&OpContext {
dir,
db_path: &db,
env: &env,
timeout: None,
cache,
},
)
.expect("the fetch succeeds");
}
#[cfg(feature = "http-fetch")]
#[test]
fn test_fresh_intact_sidecar_keeps_the_refetch_conditional() {
let dir = tempfile::tempdir().unwrap();
let bytes = b"cik,lei\n0000320193,HWUPKR0MPOU8FGXBT394\n";
let (url, requests) = etag_server(bytes);
fetched_then_replaced(dir.path(), &url, bytes, bytes);
http_fetch_into(dir.path(), &url);
assert_eq!(
*requests.lock().unwrap(),
vec![true],
"the stored ETag was replayed"
);
}
#[cfg(feature = "http-fetch")]
#[test]
fn test_fresh_refusal_forces_an_unconditional_refetch_that_replaces_the_bytes() {
let dir = tempfile::tempdir().unwrap();
let good = b"cik,lei\n0000320193,HWUPKR0MPOU8FGXBT394\n";
let corrupt = b"cik,lei\n0000320193,HWUPKR0MPOU8FGXBT395\n";
let (url, requests) = etag_server(good);
fetched_then_replaced(dir.path(), &url, good, corrupt);
let out = dir.path().join("data.parquet");
assert!(
!fresh("data.parquet")
.evaluate(dir.path(), "test-step", &empty_env())
.unwrap(),
"a corrupted artifact must not pass the gate"
);
assert!(
!crate::ingress_meta::meta_path(&out).exists(),
"the refusal discards the sidecar"
);
http_fetch_into(dir.path(), &url);
assert_eq!(
*requests.lock().unwrap(),
vec![false],
"the re-fetch carried no validator"
);
assert_eq!(
fs::read(&out).unwrap(),
good,
"the corrupted bytes are replaced by the remote's"
);
let rewritten = crate::ingress_meta::read(&out).expect("the fetch rewrote the sidecar");
assert_eq!(rewritten.sha256, crate::state::content_hash(good));
assert!(
local_copy_refusal(&out, &rewritten).is_none(),
"the repaired artifact passes the gate"
);
}
#[cfg(feature = "http-fetch")]
#[test]
fn test_fresh_refusal_is_repaired_from_the_shared_cache_without_a_download() {
let good = b"cik,lei\n0000320193,HWUPKR0MPOU8FGXBT394\n";
let corrupt = b"cik,lei\n0000320193,HWUPKR0MPOU8FGXBT395\n";
let (url, requests) = etag_server(good);
let store = tempfile::tempdir().unwrap();
let cache = crate::fetch_cache::FetchCache::at(store.path().join("cache"));
let seeded = tempfile::tempdir().unwrap();
http_fetch_into_with_cache(seeded.path(), &url, Some(&cache));
assert!(cache.holds(&url));
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("data.parquet");
fetched_then_replaced(dir.path(), &url, good, corrupt);
assert!(
!fresh("data.parquet")
.evaluate(dir.path(), "test-step", &empty_env())
.unwrap(),
"a corrupted artifact must not pass the gate"
);
requests.lock().unwrap().clear();
http_fetch_into_with_cache(dir.path(), &url, Some(&cache));
assert_eq!(
*requests.lock().unwrap(),
vec![true],
"the re-fetch was conditional, so the server answered 304 and sent no body"
);
assert_eq!(
fs::read(&out).unwrap(),
good,
"and the rotted bytes are replaced from the store"
);
let rewritten = crate::ingress_meta::read(&out).expect("the sidecar is rewritten");
assert_eq!(rewritten.sha256, crate::state::content_hash(good));
assert!(local_copy_refusal(&out, &rewritten).is_none());
}
#[cfg(feature = "http-fetch")]
#[test]
fn test_fresh_sidecar_without_sha256_is_repaired_by_one_unconditional_refetch() {
let dir = tempfile::tempdir().unwrap();
let good = b"cik,lei\n0000320193,HWUPKR0MPOU8FGXBT394\n";
let (url, requests) = etag_server(good);
let out = dir.path().join("data.parquet");
fs::write(&out, good).unwrap();
crate::ingress_meta::write(
&out,
&crate::ingress_meta::FetchMeta {
url: url.clone(),
etag: Some("\"v1\"".to_string()),
..Default::default()
},
)
.unwrap();
assert!(
!fresh("data.parquet")
.evaluate(dir.path(), "test-step", &empty_env())
.unwrap()
);
http_fetch_into(dir.path(), &url);
assert_eq!(*requests.lock().unwrap(), vec![false]);
assert_eq!(
crate::ingress_meta::read(&out)
.expect("sidecar rewritten")
.sha256,
crate::state::content_hash(good),
"the sidecar now carries the hash it lacked"
);
}
#[test]
fn test_fresh_refusal_is_printed_to_stderr() {
const CHILD: &str = "ARC_TEST_FRESH_REFUSAL_CHILD";
if std::env::var_os(CHILD).is_some() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("edgar.parquet");
fs::write(&out, b"cik,lei\n0000320193,HWU").unwrap();
crate::ingress_meta::write(
&out,
&crate::ingress_meta::FetchMeta {
url: "http://127.0.0.1:1/never-probed".to_string(),
sha256: crate::state::content_hash(
b"cik,lei\n0000320193,HWUPKR0MPOU8FGXBT394\n",
),
..Default::default()
},
)
.unwrap();
assert!(
!fresh("edgar.parquet")
.evaluate(dir.path(), "test-step", &empty_env())
.unwrap()
);
return;
}
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"precondition::tests::test_fresh_refusal_is_printed_to_stderr",
"--nocapture",
])
.env(CHILD, "1")
.output()
.expect("re-execute the test binary");
assert!(output.status.success(), "child run: {:?}", output.status);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("warning:"), "warns: {stderr}");
assert!(stderr.contains("edgar.parquet"), "names the file: {stderr}");
assert!(
stderr.contains("local copy"),
"names the local copy: {stderr}"
);
assert!(
stderr.contains("the remote was not consulted"),
"clears the remote: {stderr}"
);
assert!(
stderr.contains(".arcmeta is discarded, so the re-fetch replaces these bytes"),
"states the repair it performed: {stderr}"
);
}
#[test]
fn test_fresh_refusal_names_the_file_and_the_local_copy() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("edgar.parquet");
fs::write(&out, b"cik,lei\n0000320193,HWU").unwrap();
let meta = crate::ingress_meta::FetchMeta {
sha256: crate::state::content_hash(b"cik,lei\n0000320193,HWUPKR0MPOU8FGXBT394\n"),
..Default::default()
};
let msg = local_copy_refusal(&out, &meta).expect("a mismatch is refused");
assert!(msg.contains("edgar.parquet"), "names the file: {msg}");
assert!(msg.contains("local copy"), "names the local copy: {msg}");
assert!(
msg.contains("the remote was not consulted"),
"clears the remote: {msg}"
);
assert!(!msg.contains('\n'), "single line: {msg}");
}
#[test]
fn test_fresh_refusal_for_missing_hash_names_the_local_copy() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("edgar.parquet");
fs::write(&out, b"bytes").unwrap();
let msg = local_copy_refusal(&out, &crate::ingress_meta::FetchMeta::default())
.expect("an unverifiable artifact is refused");
assert!(msg.contains("edgar.parquet"), "names the file: {msg}");
assert!(msg.contains("local copy"), "names the local copy: {msg}");
assert!(
msg.contains("the remote was not consulted"),
"clears the remote: {msg}"
);
}
fn tool(cfg: ToolConfig) -> Precondition {
Precondition::Tool { tool: cfg }
}
fn versioned(path: &str) -> Precondition {
tool(ToolConfig {
path: Some(path.to_string()),
version: Some("$ARC_TOOL".to_string()),
..ToolConfig::default()
})
}
fn by_contents_from_env(var: &str) -> Precondition {
tool(ToolConfig {
env: Some(var.to_string()),
contents: true,
..ToolConfig::default()
})
}
#[cfg(unix)]
fn write_tool(path: &Path, body: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
let dispatcher = Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/fake_tool_dispatcher.sh"
));
let mut script_name = path.as_os_str().to_owned();
script_name.push(".script");
fs::write(PathBuf::from(script_name), body).unwrap();
let _ = fs::remove_file(path);
std::os::unix::fs::symlink(dispatcher, path).unwrap();
}
#[cfg(not(unix))]
fn write_tool(path: &Path, body: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, body).unwrap();
}
fn banner(version: &str) -> String {
format!("#!/bin/sh\necho 'faketool {version}'\n")
}
fn step_ran(p: &Precondition, dir: &Path, step: &str, env: &HashMap<String, String>) {
record_all(std::slice::from_ref(p), dir, step, env);
}
#[test]
fn test_tool_manifest_load_requires_a_tool_and_an_identity() {
let manifest = |precondition: &str| {
format!(
"name: test\nsteps:\n - name: describe\n command: \"echo hi\"\n \
preconditions:\n - tool:\n{precondition}"
)
};
let accepted = crate::manifest::Manifest::from_yaml_str(&manifest(
" name: faketool\n version: \"$ARC_TOOL --version\"\n",
))
.expect("a well-formed tool precondition loads");
assert!(matches!(
accepted.steps[0].preconditions[0],
Precondition::Tool { .. }
));
let no_tool = crate::manifest::Manifest::from_yaml_str(&manifest(
" version: \"faketool --version\"\n",
))
.expect_err("a tool precondition naming no tool is refused at load");
assert!(
no_tool.to_string().contains("names no tool"),
"says which half is missing: {no_tool}"
);
assert!(
no_tool.to_string().contains("describe"),
"names the step: {no_tool}"
);
let no_identity =
crate::manifest::Manifest::from_yaml_str(&manifest(" name: faketool\n"))
.expect_err("a tool precondition with no identity is refused at load");
assert!(
no_identity
.to_string()
.contains("says nothing about what identifies the tool"),
"says which half is missing: {no_identity}"
);
}
#[test]
fn test_tool_validate_refuses_ambiguous_declarations() {
let two_tools = tool(ToolConfig {
name: Some("faketool".to_string()),
path: Some("bin/faketool".to_string()),
version: Some("$ARC_TOOL".to_string()),
..ToolConfig::default()
});
let err = two_tools.validate().unwrap_err().to_string();
assert!(err.contains("names the tool 2 ways"), "{err}");
assert!(err.contains("name, path"), "names which two: {err}");
let two_identities = tool(ToolConfig {
name: Some("faketool".to_string()),
version: Some("$ARC_TOOL".to_string()),
contents: true,
..ToolConfig::default()
});
let err = two_identities.validate().unwrap_err().to_string();
assert!(err.contains("two identities for one tool"), "{err}");
let blank = tool(ToolConfig {
name: Some(" ".to_string()),
version: Some("$ARC_TOOL".to_string()),
..ToolConfig::default()
});
assert!(blank.validate().is_err(), "a blank name names no tool");
}
#[test]
fn test_tool_resolves_from_path_from_a_file_path_and_from_an_env_var() {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("bin");
write_tool(&bin.join("faketool"), &banner("1.0.0"));
let mut env = empty_env();
env.insert(
"PATH".to_string(),
format!("{}:/usr/bin:/bin", bin.display()),
);
env.insert(
"FAKETOOL_BIN".to_string(),
bin.join("faketool").display().to_string(),
);
let on_path = tool(ToolConfig {
name: Some("faketool".to_string()),
version: Some("$ARC_TOOL".to_string()),
..ToolConfig::default()
});
let at_path = tool(ToolConfig {
path: Some(bin.join("faketool").display().to_string()),
version: Some("$ARC_TOOL".to_string()),
..ToolConfig::default()
});
let from_env = tool(ToolConfig {
env: Some("FAKETOOL_BIN".to_string()),
version: Some("$ARC_TOOL".to_string()),
..ToolConfig::default()
});
for (label, p) in [
("PATH", &on_path),
("absolute path", &at_path),
("env var", &from_env),
] {
let step = format!("step-via-{label}");
assert!(
!p.evaluate(dir.path(), &step, &env).unwrap(),
"{label}: with nothing recorded the step is stale"
);
step_ran(p, dir.path(), &step, &env);
assert!(
p.evaluate(dir.path(), &step, &env).unwrap(),
"{label}: the same tool as the last run is fresh"
);
}
}
#[test]
fn test_tool_version_decides_the_verdict() {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("bin/faketool");
write_tool(&bin, &banner("1.0.0"));
let p = versioned("bin/faketool");
assert!(
!p.evaluate(dir.path(), "describe", &empty_env()).unwrap(),
"a step that has never run against this tool is stale"
);
step_ran(&p, dir.path(), "describe", &empty_env());
assert!(
p.evaluate(dir.path(), "describe", &empty_env()).unwrap(),
"an unchanged tool is fresh"
);
write_tool(&bin, &banner("1.0.1"));
assert!(
!p.evaluate(dir.path(), "describe", &empty_env()).unwrap(),
"a tool reporting a different version is stale"
);
step_ran(&p, dir.path(), "describe", &empty_env());
assert!(p.evaluate(dir.path(), "describe", &empty_env()).unwrap());
}
#[test]
fn test_tool_contents_redden_when_the_artifact_is_rebuilt_in_place() {
let dir = tempfile::tempdir().unwrap();
let ext = dir.path().join("build/finetype.duckdb_extension");
fs::create_dir_all(ext.parent().unwrap()).unwrap();
fs::write(&ext, b"EXTENSION-BUILD-AAAA").unwrap();
let mut env = empty_env();
env.insert("FINETYPE_EXT".to_string(), ext.display().to_string());
let p = by_contents_from_env("FINETYPE_EXT");
step_ran(&p, dir.path(), "gate", &env);
assert!(
p.evaluate(dir.path(), "gate", &env).unwrap(),
"the artifact the step last ran against is fresh"
);
fs::write(&ext, b"EXTENSION-BUILD-BBBB").unwrap();
assert_eq!(
fs::metadata(&ext).unwrap().len(),
"EXTENSION-BUILD-AAAA".len() as u64,
"the rebuild changed no size, so only the bytes can carry the difference"
);
assert!(
!p.evaluate(dir.path(), "gate", &env).unwrap(),
"a rebuilt artifact at an unchanged path is stale"
);
}
#[test]
fn test_tool_that_cannot_be_identified_halts_and_names_the_step() {
let dir = tempfile::tempdir().unwrap();
let empty_path_dir = dir.path().join("empty");
fs::create_dir_all(&empty_path_dir).unwrap();
let mut path_env = empty_env();
path_env.insert("PATH".to_string(), empty_path_dir.display().to_string());
let unreadable_dir = dir.path().join("notexec");
fs::create_dir_all(&unreadable_dir).unwrap();
fs::write(unreadable_dir.join("faketool"), "not executable").unwrap();
let mut notexec_env = empty_env();
notexec_env.insert("PATH".to_string(), unreadable_dir.display().to_string());
let mut pointing_nowhere = empty_env();
pointing_nowhere.insert(
"FAKETOOL_BIN".to_string(),
dir.path().join("gone/faketool").display().to_string(),
);
write_tool(&dir.path().join("bin/broken"), "#!/bin/sh\nexit 3\n");
let named = |name: &str| {
tool(ToolConfig {
name: Some(name.to_string()),
version: Some("$ARC_TOOL".to_string()),
..ToolConfig::default()
})
};
let from_env = tool(ToolConfig {
env: Some("FAKETOOL_BIN".to_string()),
version: Some("$ARC_TOOL".to_string()),
..ToolConfig::default()
});
let unset_var = tool(ToolConfig {
env: Some("ARC_TEST_NEVER_SET_VAR".to_string()),
contents: true,
..ToolConfig::default()
});
let cases: [(&str, &Precondition, &HashMap<String, String>, &str); 6] = [
("not on PATH", &named("faketool"), &path_env, "on PATH"),
(
"on PATH but not executable",
&named("faketool"),
¬exec_env,
"on PATH",
),
(
"env var points at nothing",
&from_env,
&pointing_nowhere,
"does not exist",
),
(
"env var unset",
&unset_var,
&empty_env(),
"ARC_TEST_NEVER_SET_VAR",
),
(
"the file is missing",
&versioned("bin/absent"),
&empty_env(),
"does not exist",
),
(
"the version command fails",
&versioned("bin/broken"),
&empty_env(),
"exited 3",
),
];
for (label, p, env, expected) in cases {
let result = p.evaluate(dir.path(), "describe", env);
let err = match result {
Err(e) => e,
Ok(verdict) => panic!("{label}: evaluated as {verdict} instead of halting"),
};
let msg = err.to_string();
assert!(msg.contains("describe"), "{label}: names the step: {msg}");
assert!(
msg.contains(expected),
"{label}: says where it got to: {msg}"
);
}
}
#[test]
fn test_tool_identity_is_dropped_when_the_tool_goes_away() {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("bin/faketool");
write_tool(&bin, &banner("1.0.0"));
let p = versioned("bin/faketool");
step_ran(&p, dir.path(), "describe", &empty_env());
assert!(p.evaluate(dir.path(), "describe", &empty_env()).unwrap());
fs::remove_file(&bin).unwrap();
step_ran(&p, dir.path(), "describe", &empty_env());
write_tool(&bin, &banner("1.0.0"));
assert!(
!p.evaluate(dir.path(), "describe", &empty_env()).unwrap(),
"the identity dropped when the tool vanished is not silently restored"
);
}
#[test]
fn test_tool_evaluation_records_nothing() {
let dir = tempfile::tempdir().unwrap();
write_tool(&dir.path().join("bin/faketool"), &banner("1.0.0"));
let p = versioned("bin/faketool");
assert!(!p.evaluate(dir.path(), "describe", &empty_env()).unwrap());
assert!(
!stamp_store_path(dir.path()).exists(),
"evaluate wrote a stamp store"
);
assert!(
!p.evaluate(dir.path(), "describe", &empty_env()).unwrap(),
"a second look reaches the same verdict"
);
}
#[test]
fn test_tool_two_declarations_on_one_step_are_tracked_separately() {
let dir = tempfile::tempdir().unwrap();
write_tool(&dir.path().join("bin/alpha"), &banner("1.0.0"));
write_tool(&dir.path().join("bin/beta"), &banner("2.0.0"));
let alpha = versioned("bin/alpha");
let beta = versioned("bin/beta");
let both = vec![alpha.clone(), beta.clone()];
record_all(&both, dir.path(), "describe", &empty_env());
assert!(evaluate_all(&both, dir.path(), "describe", &empty_env()).unwrap());
write_tool(&dir.path().join("bin/beta"), &banner("2.0.1"));
assert!(
!evaluate_all(&both, dir.path(), "describe", &empty_env()).unwrap(),
"one moved tool is enough to make the step stale"
);
assert!(
alpha
.evaluate(dir.path(), "describe", &empty_env())
.unwrap(),
"and the tool that did not move is still fresh"
);
}
#[test]
fn test_tool_identities_are_per_step() {
let dir = tempfile::tempdir().unwrap();
write_tool(&dir.path().join("bin/faketool"), &banner("1.0.0"));
let p = versioned("bin/faketool");
step_ran(&p, dir.path(), "describe", &empty_env());
assert!(p.evaluate(dir.path(), "describe", &empty_env()).unwrap());
assert!(
!p.evaluate(dir.path(), "validate", &empty_env()).unwrap(),
"a step that has not run is stale whatever its sibling recorded"
);
}
#[test]
fn test_tool_serde_roundtrip() {
let yaml = serde_yaml::to_string(&vec![
versioned("bin/faketool"),
by_contents_from_env("FINETYPE_EXT"),
])
.unwrap();
assert!(
!yaml.contains("null"),
"an unset resolver is omitted rather than serialised as null: {yaml}"
);
let parsed: Vec<Precondition> = serde_yaml::from_str(&yaml).unwrap();
assert!(matches!(parsed[0], Precondition::Tool { .. }));
assert!(matches!(parsed[1], Precondition::Tool { .. }));
match &parsed[1] {
Precondition::Tool { tool } => {
assert_eq!(tool.env.as_deref(), Some("FINETYPE_EXT"));
assert!(tool.contents);
assert!(tool.version.is_none());
}
other => panic!("expected a tool precondition: {other:?}"),
}
}
#[test]
fn test_fresh_validate_and_roundtrip() {
assert!(fresh("build/x.parquet").validate().is_ok());
assert!(fresh("").validate().is_err());
let yaml = serde_yaml::to_string(&vec![fresh("build/x.parquet")]).unwrap();
let parsed: Vec<Precondition> = serde_yaml::from_str(&yaml).unwrap();
assert!(matches!(parsed[0], Precondition::Fresh { .. }));
}
}