cavs-cli 1.0.0

Package, verify and deliver deduplicated game content as .cavs files.
//! `cavs certify export-repro` — bundle everything another developer needs
//! to verify a certification run: commands, environment, tool versions,
//! reports, hashes and configs. Private input builds stay out unless
//! `--include-inputs` is passed explicitly.

use anyhow::{Context, Result};
use std::io::Write as _;
use std::path::{Path, PathBuf};

use super::{CheckResult, CheckRow};

/// Report and output files bundled from the certification directory root.
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 {
    /// Path inside the bundle, always under `repro/`.
    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,
    });

    // Commands + environment.
    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()
        },
    ));

    // Tool versions: prefer dependencies.json, fall back to environment.json.
    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",
        )),
    }

    // Outputs (JSON) and reports (Markdown).
    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,
    });

    // Input hashes + file list (never the bytes, unless asked).
    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,
        });
    }

    // Configs and seeds, when present.
    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)?;

    // Optionally include the original inputs (from summary.json old/new).
    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)",
        ));
    }

    // -- Deterministic tar.zst -------------------------------------------------
    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)
}