crates/nornir-build-thing/src/measure/approval.rs
//! **The review step**: a reviewer's approval of a row's profiles, kept in
//! `.nornir/profiles/<row>/approved.toml` beside them. Per program × arch ×
//! libc it holds the sha256 of each reviewed `.json` / `.oci.json`, the
//! reviewer's NAME (never an e-mail address), the date and a note.
//! [`approval_status`] compares it with the files on disk: a profile that
//! changed after its approval, was never approved, or vanished is named.
//! The `.rs` table and the `.probe.json` evidence are not reviewed files.
use std::path::Path;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use super::{LIBCS, MeasureManifest, PROFILES_DIR, row_dir};
/// The approval file, beside the row's `measure.toml`.
pub const APPROVAL: &str = "approved.toml";
/// `approved.toml`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ApprovalFile {
pub row: String,
#[serde(rename = "approved", default)]
pub entries: Vec<ApprovalEntry>,
}
/// One reviewed file.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ApprovalEntry {
pub program: String,
pub arch: String,
pub libc: String,
/// Relative to the row dir: `<program>/<arch>-<libc>.json`.
pub file: String,
pub sha256: String,
pub reviewer: String,
/// `YYYY-MM-DD`.
pub date: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub note: String,
}
/// Where one reviewed file stands.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalState {
/// On disk with the approved sha256.
Approved,
/// On disk, changed since its approval.
Changed,
/// On disk, never approved.
NotApproved,
/// Approved, no longer on disk.
Missing,
}
/// One reviewed file and its state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct FileApproval {
pub program: String,
pub arch: String,
pub libc: String,
/// Relative to the repo root.
pub file: String,
pub state: ApprovalState,
/// The file's sha256 now (`None`: gone).
pub sha256: Option<String>,
pub approved_sha256: Option<String>,
pub reviewer: Option<String>,
pub date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
impl FileApproval {
/// The one line a gate prints, `None` when approved.
pub fn problem(&self) -> Option<String> {
let date = self.date.as_deref().unwrap_or("?");
let what = match self.state {
ApprovalState::Approved => return None,
ApprovalState::Changed => format!("changed since {date}"),
ApprovalState::NotApproved => "was never approved".to_string(),
ApprovalState::Missing => format!("was approved on {date} and is gone"),
};
Some(format!("not approved: {} {} {what}", self.program, self.file))
}
}
/// A row's approval, file by file (sorted by file).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ApprovalStatus {
pub row: String,
/// The approval file, relative to the repo root.
pub path: String,
/// The approval file exists (the row has been reviewed at least once).
pub exists: bool,
pub files: Vec<FileApproval>,
}
impl ApprovalStatus {
/// Every reviewed file approved, and there is at least one.
pub fn ok(&self) -> bool {
!self.files.is_empty() && self.files.iter().all(|f| f.state == ApprovalState::Approved)
}
/// One line per file that is not approved.
pub fn problems(&self) -> Vec<String> {
self.files.iter().filter_map(FileApproval::problem).collect()
}
}
/// The reviewed files on disk: (program, arch, libc, row-relative file, sha256).
fn reviewed_files(repo_root: &Path, m: &MeasureManifest) -> Result<Vec<(String, String, String, String, String)>> {
let dir = row_dir(repo_root, &m.row);
let mut out = Vec::new();
for p in m.programs.iter().filter(|p| p.needs_vm.is_none()) {
let Ok(rd) = std::fs::read_dir(dir.join(&p.name)) else {
continue;
};
for e in rd.flatten() {
let Ok(name) = e.file_name().into_string() else {
continue;
};
let stem = match name.strip_suffix(".oci.json") {
Some(s) => s,
None => match name.strip_suffix(".json") {
Some(s) if !s.ends_with(".probe") => s,
_ => continue,
},
};
let Some((arch, libc)) = stem.rsplit_once('-') else {
continue;
};
if arch.is_empty() || !LIBCS.contains(&libc) {
continue;
}
let bytes = std::fs::read(e.path()).with_context(|| format!("read {}", e.path().display()))?;
out.push((
p.name.clone(),
arch.to_string(),
libc.to_string(),
format!("{}/{name}", p.name),
nornir_hash::sha256_hex(&bytes),
));
}
}
out.sort_by(|a, b| a.3.cmp(&b.3));
Ok(out)
}
fn approval_exists(repo_root: &Path, row: &str) -> bool {
row_dir(repo_root, row).join(APPROVAL).is_file()
}
fn load_approval(repo_root: &Path, row: &str) -> Result<ApprovalFile> {
let path = row_dir(repo_root, row).join(APPROVAL);
match std::fs::read_to_string(&path) {
Err(_) => Ok(ApprovalFile {
row: row.into(),
entries: Vec::new(),
}),
Ok(t) => {
let f: ApprovalFile =
toml::from_str(&t).with_context(|| format!("{PROFILES_DIR}/{row}/{APPROVAL}"))?;
if f.row != row {
bail!("{PROFILES_DIR}/{row}/{APPROVAL}: row = {:?}, but it lives under row `{row}`", f.row);
}
Ok(f)
}
}
}
/// **Where a row's review stands**: every `.json` / `.oci.json` of its probed
/// programs against `approved.toml`. Read-only; the forge drift gate calls it.
pub fn approval_status(repo_root: &Path, row: &str) -> Result<ApprovalStatus> {
let m = MeasureManifest::load(repo_root, row)?;
let a = load_approval(repo_root, row)?;
let shown = |f: &str| format!("{PROFILES_DIR}/{row}/{f}");
let mut files = Vec::new();
let on_disk = reviewed_files(repo_root, &m)?;
for (program, arch, libc, file, sha) in &on_disk {
let e = a.entries.iter().find(|e| &e.file == file);
let state = match e {
None => ApprovalState::NotApproved,
Some(e) if &e.sha256 == sha => ApprovalState::Approved,
Some(_) => ApprovalState::Changed,
};
files.push(FileApproval {
program: program.clone(),
arch: arch.clone(),
libc: libc.clone(),
file: shown(file),
state,
sha256: Some(sha.clone()),
approved_sha256: e.map(|e| e.sha256.clone()),
reviewer: e.map(|e| e.reviewer.clone()),
date: e.map(|e| e.date.clone()),
note: e.map(|e| e.note.clone()).filter(|n| !n.is_empty()),
});
}
for e in &a.entries {
if !on_disk.iter().any(|f| f.3 == e.file) {
files.push(FileApproval {
program: e.program.clone(),
arch: e.arch.clone(),
libc: e.libc.clone(),
file: shown(&e.file),
state: ApprovalState::Missing,
sha256: None,
approved_sha256: Some(e.sha256.clone()),
reviewer: Some(e.reviewer.clone()),
date: Some(e.date.clone()),
note: Some(e.note.clone()).filter(|n| !n.is_empty()),
});
}
}
files.sort_by(|x, y| x.file.cmp(&y.file));
Ok(ApprovalStatus {
row: row.into(),
path: shown(APPROVAL),
exists: approval_exists(repo_root, row),
files,
})
}
/// **Approve** a row's profiles as they are on disk now: new and changed
/// files get this reviewer, date and note; unchanged ones keep theirs; gone
/// ones are dropped. `reviewer` is a name, never an e-mail address.
pub fn approve(repo_root: &Path, row: &str, reviewer: &str, date: &str, note: &str) -> Result<ApprovalStatus> {
let reviewer = reviewer.trim();
if reviewer.is_empty() {
bail!("the reviewer has no name — set one with `git config user.name \"Your Name\"`");
}
if reviewer.contains('@') {
bail!("the reviewer {reviewer:?} looks like an e-mail address — the approval records a name, never an e-mail");
}
let m = MeasureManifest::load(repo_root, row)?;
let old = load_approval(repo_root, row)?;
let on_disk = reviewed_files(repo_root, &m)?;
if on_disk.is_empty() {
bail!("row `{row}`: nothing to approve — it has no profiles yet; measure it first (measure syscalls {row})");
}
let entries = on_disk
.into_iter()
.map(|(program, arch, libc, file, sha256)| {
match old.entries.iter().find(|e| e.file == file && e.sha256 == sha256) {
Some(e) => e.clone(),
None => ApprovalEntry {
program,
arch,
libc,
file,
sha256,
reviewer: reviewer.into(),
date: date.into(),
note: note.into(),
},
}
})
.collect();
let f = ApprovalFile {
row: row.into(),
entries,
};
let mut text = format!(
"# The review of row `{row}`'s profiles: the sha256 of each reviewed file, who\n\
# reviewed it and when. Written by the approve verb; a profile that changes\n\
# after this needs a new review.\n\n"
);
text.push_str(&toml::to_string(&f).context("serialize approved.toml")?);
let path = row_dir(repo_root, row).join(APPROVAL);
std::fs::write(&path, text).with_context(|| format!("write {}", path.display()))?;
approval_status(repo_root, row)
}