use anyhow::{Context, Result};
use std::io::Write as _;
use std::path::{Path, PathBuf};
use super::{CheckResult, CheckRow};
const REPORT_EXTS: &[&str] = &["md"];
const OUTPUT_JSON: &[&str] = &[
"summary.json",
"routes.json",
"integrity.json",
"regressions.json",
"godot.json",
"workspace.json",
"dependencies.json",
];
const CONFIGS: &[&str] = &["cavs.toml", "policy.toml"];
struct Entry {
name: String,
bytes: Vec<u8>,
executable: bool,
}
fn push_file(entries: &mut Vec<Entry>, name: &str, path: &Path, executable: bool) -> Result<bool> {
if !path.is_file() {
return Ok(false);
}
entries.push(Entry {
name: format!("repro/{name}"),
bytes: std::fs::read(path).with_context(|| format!("cannot read {}", path.display()))?,
executable,
});
Ok(true)
}
fn readme(cert: &Path, include_inputs: bool) -> String {
format!(
"# CAVS reproducibility bundle\n\n\
Generated by `cavs certify export-repro` from `{}`.\n\n\
To verify this certification:\n\n\
1. Read `environment.json` and `tool-versions.json` — the exact\n \
environment and tool set of the original run.\n\
2. Re-run the commands in `commands.sh` against the same inputs\n \
(their hashes are in `inputs/hashes.json`).\n\
3. Compare your reports with `outputs/*.json` and `reports/*.md`;\n \
`outputs/report-hashes.json` carries a BLAKE3 per bundled report.\n\n\
{}\n",
cert.display(),
if include_inputs {
"This bundle includes the input files (`inputs/files/`)."
} else {
"By design this bundle does NOT include the input builds — only \
their hashes. Pass --include-inputs only for synthetic or \
shareable test data."
}
)
}
pub fn export(cert: &Path, out: &Path, include_inputs: bool) -> Result<Vec<CheckRow>> {
let mut rows = Vec::new();
let mut entries: Vec<Entry> = Vec::new();
entries.push(Entry {
name: "repro/README.md".into(),
bytes: readme(cert, include_inputs).into_bytes(),
executable: false,
});
let has_commands = push_file(&mut entries, "commands.sh", &cert.join("commands.sh"), true)?;
let has_env = push_file(
&mut entries,
"environment.json",
&cert.join("environment.json"),
false,
)?;
rows.push(CheckRow::new(
"command list",
if has_commands {
CheckResult::Pass
} else {
CheckResult::Warn
},
if has_commands {
"commands.sh bundled".into()
} else {
"no commands.sh in the certification directory".to_string()
},
));
rows.push(CheckRow::new(
"environment metadata",
if has_env {
CheckResult::Pass
} else {
CheckResult::Warn
},
if has_env {
"environment.json bundled".into()
} else {
"no environment.json in the certification directory".to_string()
},
));
let tool_versions = ["dependencies.json", "environment.json"]
.iter()
.map(|f| cert.join(f))
.find(|p| p.is_file());
match &tool_versions {
Some(p) => {
push_file(&mut entries, "tool-versions.json", p, false)?;
rows.push(CheckRow::new(
"tool versions",
CheckResult::Pass,
format!("from {}", p.file_name().unwrap().to_string_lossy()),
));
}
None => rows.push(CheckRow::new(
"tool versions",
CheckResult::Warn,
"no dependencies.json/environment.json found",
)),
}
let mut report_hashes: Vec<(String, String)> = Vec::new();
for f in OUTPUT_JSON {
if push_file(&mut entries, &format!("outputs/{f}"), &cert.join(f), false)? {
let bytes = std::fs::read(cert.join(f))?;
report_hashes.push((
format!("outputs/{f}"),
cavs_hash::to_hex(&cavs_hash::hash_chunk(&bytes)),
));
}
}
let mut md_count = 0;
let mut names: Vec<PathBuf> = std::fs::read_dir(cert)?
.filter_map(|e| e.ok().map(|e| e.path()))
.collect();
names.sort();
for path in &names {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if REPORT_EXTS.contains(&ext) {
let name = format!("reports/{}", path.file_name().unwrap().to_string_lossy());
if push_file(&mut entries, &name, path, false)? {
md_count += 1;
let bytes = std::fs::read(path)?;
report_hashes.push((name, cavs_hash::to_hex(&cavs_hash::hash_chunk(&bytes))));
}
}
}
rows.push(CheckRow::new(
"reports",
if md_count > 0 {
CheckResult::Pass
} else {
CheckResult::Warn
},
format!(
"{md_count} Markdown reports, {} JSON outputs",
report_hashes.len() - md_count
),
));
entries.push(Entry {
name: "repro/outputs/report-hashes.json".into(),
bytes: serde_json::to_vec_pretty(
&report_hashes
.iter()
.map(|(n, h)| serde_json::json!({"file": n, "blake3": h}))
.collect::<Vec<_>>(),
)?,
executable: false,
});
let hashes = cert.join("artifacts").join("hashes.json");
let has_hashes = push_file(&mut entries, "inputs/hashes.json", &hashes, false)?;
rows.push(CheckRow::new(
"input hashes",
if has_hashes {
CheckResult::Pass
} else {
CheckResult::Warn
},
if has_hashes {
"inputs/hashes.json bundled".into()
} else {
"no artifacts/hashes.json found".to_string()
},
));
let artifacts_dir = cert.join("artifacts");
if artifacts_dir.is_dir() {
let mut list = Vec::new();
let mut stack = vec![artifacts_dir.clone()];
while let Some(d) = stack.pop() {
for e in std::fs::read_dir(&d)?.flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if let Ok(meta) = p.metadata() {
list.push(serde_json::json!({
"file": p.strip_prefix(cert).unwrap_or(&p).display().to_string(),
"bytes": meta.len(),
}));
}
}
}
list.sort_by(|a, b| a["file"].as_str().cmp(&b["file"].as_str()));
entries.push(Entry {
name: "repro/inputs/file-list.json".into(),
bytes: serde_json::to_vec_pretty(&list)?,
executable: false,
});
}
for cfg in CONFIGS {
push_file(
&mut entries,
&format!("configs/{cfg}"),
&cert.join(cfg),
false,
)?;
}
let seed = cert.join("seeds").join("dataset-seed.txt");
push_file(&mut entries, "seeds/dataset-seed.txt", &seed, false)?;
if include_inputs {
let mut included = 0;
if let Ok(bytes) = std::fs::read(cert.join("summary.json")) {
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
for key in ["old", "new"] {
if let Some(p) = v.get(key).and_then(|p| p.as_str()) {
let path = Path::new(p);
if path.is_file() {
let name = format!(
"inputs/files/{key}/{}",
path.file_name().unwrap().to_string_lossy()
);
push_file(&mut entries, &name, path, false)?;
included += 1;
} else if path.is_dir() {
let mut stack = vec![path.to_path_buf()];
while let Some(d) = stack.pop() {
for e in std::fs::read_dir(&d)?.flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if p.is_file() {
let rel = p.strip_prefix(path).unwrap();
push_file(
&mut entries,
&format!("inputs/files/{key}/{}", rel.display()),
&p,
false,
)?;
included += 1;
}
}
}
}
}
}
}
}
rows.push(CheckRow::new(
"inputs included",
CheckResult::Warn,
format!(
"{included} input files bundled on request — do this only for \
synthetic or shareable data"
),
));
} else {
rows.push(CheckRow::new(
"no private inputs",
CheckResult::Pass,
"input bytes excluded by default (hashes only)",
));
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
entries.dedup_by(|a, b| a.name == b.name);
let mut tar_bytes: Vec<u8> = Vec::new();
{
let mut builder = tar::Builder::new(&mut tar_bytes);
for e in &entries {
let mut header = tar::Header::new_gnu();
header.set_size(e.bytes.len() as u64);
header.set_mode(if e.executable { 0o755 } else { 0o644 });
header.set_mtime(0);
header.set_uid(0);
header.set_gid(0);
header.set_cksum();
builder.append_data(&mut header, &e.name, e.bytes.as_slice())?;
}
builder.finish()?;
}
let compressed = zstd::stream::encode_all(tar_bytes.as_slice(), 19)?;
if let Some(parent) = out.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let mut f = std::fs::File::create(out)?;
f.write_all(&compressed)?;
rows.push(CheckRow::new(
"bundle",
CheckResult::Pass,
format!(
"{} entries, {} (deterministic: sorted entries, zeroed timestamps)",
entries.len(),
crate::report::human_bytes(compressed.len() as u64)
),
));
Ok(rows)
}