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
}