use std::path::Path;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
pub const ATTESTATION_PATH: &str = "e2e-attestation.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Attestation {
pub command: String,
pub ran_at: u64,
pub exit_code: i32,
pub commit: String,
}
pub fn attest(repo: &Path, command: &str) -> Result<Attestation> {
let commit = git_capture(repo, &["rev-parse", "HEAD"])
.context("resolving HEAD — `e2e attest` must run inside a git repo with a commit")?;
let status = Command::new("sh")
.arg("-c")
.arg(command)
.current_dir(repo)
.status()
.with_context(|| format!("running e2e command `{command}`"))?;
let exit_code = status.code().unwrap_or(-1);
let ran_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let attestation = Attestation {
command: command.to_string(),
ran_at,
exit_code,
commit: commit.clone(),
};
let path = repo.join(ATTESTATION_PATH);
let json = serde_json::to_string_pretty(&attestation).context("serializing the attestation")?;
std::fs::write(&path, format!("{json}\n"))
.with_context(|| format!("writing {}", path.display()))?;
git_run(repo, &["add", ATTESTATION_PATH])?;
let short = &commit[..commit.len().min(7)];
let message = format!("e2e attestation for {short}");
git_run(repo, &["commit", "-q", "-m", message.as_str()])?;
Ok(attestation)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verification {
Fresh,
Missing,
Stale {
attested: String,
latest: String,
},
}
pub fn verify(repo: &Path) -> Result<Verification> {
let path = repo.join(ATTESTATION_PATH);
let Ok(contents) = std::fs::read_to_string(&path) else {
return Ok(Verification::Missing);
};
let attestation: Attestation =
serde_json::from_str(&contents).context("parsing the attestation")?;
let latest = latest_code_commit(repo)?;
if attestation.commit == latest {
Ok(Verification::Fresh)
} else {
Ok(Verification::Stale {
attested: attestation.commit,
latest,
})
}
}
fn latest_code_commit(repo: &Path) -> Result<String> {
let exclude = format!(":(exclude){ATTESTATION_PATH}");
git_capture(
repo,
&["log", "-1", "--format=%H", "--", ".", exclude.as_str()],
)
}
fn git_capture(repo: &Path, args: &[&str]) -> Result<String> {
let out = Command::new("git")
.args(args)
.current_dir(repo)
.output()
.with_context(|| format!("running `git {}`", args.join(" ")))?;
if !out.status.success() {
bail!(
"`git {}` failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8(out.stdout)?.trim().to_string())
}
fn git_run(repo: &Path, args: &[&str]) -> Result<()> {
let status = Command::new("git")
.args(args)
.current_dir(repo)
.status()
.with_context(|| format!("running `git {}`", args.join(" ")))?;
if !status.success() {
bail!("`git {}` failed", args.join(" "));
}
Ok(())
}