Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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)
}