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

nordisk/gunnar

xtask/src/ci_check.rs

//! `cargo xtask ci-check`: the workflows in `.github/`, asserted.
//!
//! # Why a repository whose CI is a command has workflow files at all
//!
//! `xtask/src/main.rs` argues, and keeps arguing, that `cargo xtask ci` is the
//! whole of gunnar's CI and that it must stay host-agnostic. That is right, and
//! it has one hole: a command a human types is a command a human can forget.
//! The 0-known-CVE gate was a step of it and a door inside one machine's deploy
//! script, so "0 known CVE" was proved per deploy and per remembered
//! invocation — never per push, and never on the branch that went straight to a
//! tag. The same was true of the test suite.
//!
//! So `.github/workflows/ci.yml` exists, and every step in it is ONE `cargo`
//! line. The rules stay in Rust; the workflow only says *when*.
//!
//! **That last sentence is a convention and NOT a rule here, deliberately.** A
//! check for it would have to read a `run: |` block scalar as a shell script,
//! which is parsing, and `mirror-safety.yml` is a hand-maintained mirror of a
//! server-side pre-receive hook that legitimately is one. A check that cannot
//! fire against the very file it would be aimed at is worse than no check,
//! because it is believed — so the convention is written down and the two rules
//! that CAN be checked mechanically are the two below.
//!
//! # This is a scanner, not a YAML parser
//!
//! The same deliberate limit `deploy_check` records, for the same reason:
//! `xtask` has zero dependencies, so there is no `serde_yaml` here and there is
//! not going to be one. It reads the documents as text and checks for exact
//! strings. That is weaker than parsing and can be evaded by writing the same
//! YAML a different way — and it still catches the failures that actually
//! happen, which are edits.
//!
//! # Every check is proved to bite
//!
//! [`Injury`], exactly as in `deploy_check`: a deliberate defect applied to a
//! copy of the workflow in memory, and `xtask/tests/ci_check.rs` fails if the
//! check comes out green on it.

use std::path::Path;

/// Where the workflows live, relative to the workspace root.
pub const DIR: &str = ".github/workflows";

/// **The features the suite is run with**: `cargo xtask ci`'s `test` step names exactly
/// these (`xtask/src/main.rs` reads this constant), and the workflow's `cargo test
/// --workspace` must name them too. Each is default-OFF and gates whole test targets
/// (`gunnar-cli/http`: `tests/http_transport.rs`, `tests/shallow_delta_blob.rs` and the rest
/// of the smart-HTTP suite are `#![cfg(feature = "http")]`), so a suite run without them
/// reports nothing for those targets: not a failure, not a skip, nothing (OX-WIRED4).
pub const SUITE_FEATURES: &str =
    "gunnar-lfs/fs,gunnar-cli/http,gunnar-server/fleet-pull,gunnar-server/mirror-http,gunnar-server/sso-egress,gunnar-server/heimdall";

/// A deliberate defect, for proving a check bites.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Injury {
    /// No defect: the workflows must PASS.
    None,
    /// Take the CVE gate out of the run.
    NoCveGate,
    /// Take the test suite out of the run.
    NoTestSuite,
    /// Drop the `permissions:` block, which restores the default token — the
    /// one that can write to the repository.
    DefaultTokenPermissions,
    /// Widen the token to `write-all`, the explicit spelling of the same thing.
    WriteAllPermissions,
    /// Run the suite without [`SUITE_FEATURES`]: the feature-gated test targets vanish.
    SuiteWithoutFeatures,
}

impl Injury {
    /// Every injury, for the red sweep.
    pub const ALL: &'static [Injury] = &[
        Injury::NoCveGate,
        Injury::NoTestSuite,
        Injury::DefaultTokenPermissions,
        Injury::WriteAllPermissions,
        Injury::SuiteWithoutFeatures,
    ];

    /// The name that appears in the red sweep's output.
    pub fn label(self) -> &'static str {
        match self {
            Injury::None => "none",
            Injury::NoCveGate => "no-cve-gate",
            Injury::NoTestSuite => "no-test-suite",
            Injury::DefaultTokenPermissions => "default-token-permissions",
            Injury::WriteAllPermissions => "write-all-permissions",
            Injury::SuiteWithoutFeatures => "suite-without-features",
        }
    }
}

/// The workflows, as `(file name, text)`, with an injury applied.
///
/// Read once and injured in memory, for `deploy_check`'s reason: a checker that
/// wrote a broken workflow to disk to test itself would be one crash away from
/// leaving it there.
#[derive(Debug, Clone)]
pub struct Workflows {
    pub files: Vec<(String, String)>,
}

impl Workflows {
    /// Read them from `root/.github/workflows`.
    pub fn read(root: &Path) -> Result<Workflows, String> {
        let dir = root.join(DIR);
        let entries = std::fs::read_dir(&dir).map_err(|err| {
            format!(
                "{}: {err} — there is no CI, so nothing runs the CVE gate or the suite \
                 except a human who remembers to",
                dir.display()
            )
        })?;
        let mut files = Vec::new();
        for entry in entries.flatten() {
            let path = entry.path();
            let is_yaml = path
                .extension()
                .and_then(|e| e.to_str())
                .is_some_and(|e| e == "yml" || e == "yaml");
            if !is_yaml {
                continue;
            }
            let name = path
                .file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("<unnamed>")
                .to_string();
            let text = std::fs::read_to_string(&path)
                .map_err(|err| format!("{}: {err}", path.display()))?;
            files.push((name, text));
        }
        files.sort();
        if files.is_empty() {
            return Err(format!("{} holds no workflow", dir.display()));
        }
        Ok(Workflows { files })
    }

    /// The same workflows with one defect introduced.
    pub fn injured(&self, injury: Injury) -> Workflows {
        let mut out = self.clone();
        let edit = |out: &mut Workflows, from: &str, to: &str| {
            for (_, text) in out.files.iter_mut() {
                *text = text.replace(from, to);
            }
        };
        match injury {
            Injury::None => {}
            Injury::NoCveGate => edit(&mut out, "cargo xtask cve", "cargo --version"),
            Injury::NoTestSuite => edit(&mut out, "cargo test --workspace", "cargo --version"),
            Injury::DefaultTokenPermissions => edit(&mut out, "permissions:", "permissions-was:"),
            Injury::WriteAllPermissions => edit(&mut out, "  contents: read", "  contents: write"),
            Injury::SuiteWithoutFeatures => {
                edit(&mut out, &format!("--features {SUITE_FEATURES}"), "")
            }
        }
        out
    }

    /// Every workflow's text, joined — for the questions that are about the
    /// repository's CI as a whole rather than about one file.
    fn all(&self) -> String {
        self.files
            .iter()
            .map(|(_, text)| text.as_str())
            .collect::<Vec<_>>()
            .join("\n")
    }
}

/// One rule that did not hold.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Violation {
    /// Which document, or `<all>` for a rule about the set.
    pub where_: String,
    /// What is wrong, and why it matters.
    pub what: String,
}

/// Check the workflows. An empty vector is a pass.
pub fn check(workflows: &Workflows) -> Vec<Violation> {
    let mut out = Vec::new();
    let mut fail = |where_: &str, what: String| {
        out.push(Violation {
            where_: where_.to_string(),
            what,
        })
    };
    let all = workflows.all();

    // ── 1. the two checks that have to run without being remembered ────────
    if !all.contains("cargo xtask cve") {
        fail(
            "<all>",
            "nothing runs the 0-known-CVE gate. It is a step of `cargo xtask ci` and a door \
             in one machine's deploy script, so without this it is proved per deploy and \
             per remembered invocation — never on the branch that goes straight to a tag."
                .to_owned(),
        );
    }
    if !all.contains("cargo test --workspace") {
        fail(
            "<all>",
            "nothing runs the test suite. A suite that runs when somebody remembers is \
             green on the days it matters least."
                .to_owned(),
        );
    }

    // ── 1b. the suite runs with the features `cargo xtask ci` runs it with ──
    //
    // The words cargo reads are the ones before the lone `--` (after it they are the test
    // binary's, where `--features` means nothing). Every feature of [`SUITE_FEATURES`] must
    // be in one `--features` list there.
    if let Some(at) = all.find("cargo test --workspace") {
        let words: Vec<&str> = all[at..]
            .split("\n\n")
            .next()
            .unwrap_or_default()
            .split_whitespace()
            .take_while(|w| *w != "--")
            .collect();
        let named: Vec<&str> = words
            .windows(2)
            .filter(|w| w[0] == "--features")
            .flat_map(|w| w[1].split(','))
            .collect();
        let missing: Vec<&str> = SUITE_FEATURES
            .split(',')
            .filter(|f| !named.contains(f))
            .collect();
        if !missing.is_empty() {
            fail(
                "<all>",
                format!(
                    "the suite runs without {}, which `cargo xtask ci` turns on: the test targets \
                     gated on them (the smart-HTTP suite behind `gunnar-cli/http`, …) are not \
                     compiled, so they report nothing, and the green covers less than it claims",
                    missing.join(", ")
                ),
            );
        }
    }

    // ── 2. the token can read, and that is all ─────────────────────────────
    //
    // GitHub's default `GITHUB_TOKEN` can write to the repository. No step in
    // any of these workflows writes anything, so the write is pure blast
    // radius: a compromised action in the graph gets a push token for free.
    for (name, text) in &workflows.files {
        if !text.contains("permissions:") {
            fail(
                name,
                "no `permissions:` block, so the job runs with the default GITHUB_TOKEN — \
                 which can write to this repository. Nothing here writes anything."
                    .to_owned(),
            );
            continue;
        }
        for line in text.lines() {
            let line = line.trim();
            let Some((key, value)) = line.split_once(": ") else {
                continue;
            };
            // Only the scopes inside a `permissions:` block are spelled this
            // way, and `read` is the only value any of them may take.
            if matches!(
                key,
                "contents"
                    | "actions"
                    | "packages"
                    | "id-token"
                    | "issues"
                    | "pull-requests"
                    | "checks"
                    | "deployments"
                    | "statuses"
                    | "security-events"
            ) && value.trim() != "read"
                && value.trim() != "none"
            {
                fail(
                    name,
                    format!("`{key}: {}` grants more than reading", value.trim()),
                );
            }
        }
        if text.contains("permissions: write-all") {
            fail(name, "`permissions: write-all`".to_owned());
        }
    }

    out
}

/// Apply every [`Injury`] and return the ones the check did NOT catch.
pub fn red(workflows: &Workflows) -> Vec<Injury> {
    Injury::ALL
        .iter()
        .copied()
        .filter(|injury| check(&workflows.injured(*injury)).is_empty())
        .collect()
}

/// Run the check against the workflows on disk, and print the result.
///
/// Returns `true` on a pass.
pub fn run(root: &Path) -> bool {
    let workflows = match Workflows::read(root) {
        Ok(workflows) => workflows,
        Err(why) => {
            eprintln!("xtask ci-check: {why}");
            return false;
        }
    };
    let violations = check(&workflows);
    if violations.is_empty() {
        eprintln!(
            "ci-check: {} workflow(s) hold every rule they claim",
            workflows.files.len()
        );
        return true;
    }
    for violation in &violations {
        eprintln!("ci-check: {}: {}", violation.where_, violation.what);
    }
    false
}