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

nordisk/dwarves

ci/verbs/src/badge.rs

//! `ci-verbs openssf-badge --badge <repo>=<project>... [--checkout <dir>] [--out <dir>]
//! [--base <url>]` (BADGE-7): fetch the checkout's OpenSSF Best Practices badge and write a
//! checked COPY, for the forge to publish in nordisk/ci-status.
//!
//! Why a copy: gunnar.rs's policy is `img-src 'self' data:` and Gunnar makes no outbound
//! call (the airgap rule), so its README pages cannot draw
//! `https://www.bestpractices.dev/projects/<id>/badge`. The nightly Scorecard pipeline runs
//! this step after the Scorecard; the forge publishes the file at
//! `badges/openssf-<repo>.svg` in ci-status (only when it changed), and gunnar.rs serves
//! it from its own origin (`/api/repos/nordisk/ci-status/raw?path=badges/…`).
//!
//! * `--badge <repo>=<project>`: one per repository; the step picks the one named after
//!   the checkout (`--checkout`, else `$DWARVES_CI_CHECKOUT`). The pairs are the pipeline
//!   file's data, so a new project is a line in the RON, not code here.
//! * The answer must pass [`check_svg`]: an SVG under [`MAX_BYTES`], UTF-8, with no
//!   `<script>`, no event attribute (`on…=`) and no `javascript:` URL. Anything else
//!   fails the step by name and writes nothing.
//! * It writes `<out>/publish/badges/openssf-<repo>.svg` (`--out`, else `$NORNIR_CI_OUT`,
//!   else the current directory).

use std::path::PathBuf;

/// The largest badge accepted. bestpractices.dev answers about 1.2 KB.
pub const MAX_BYTES: usize = 20 * 1024;

/// Where the badges come from.
pub const DEFAULT_BASE: &str = "https://www.bestpractices.dev";

/// **Is this a badge gunnar.rs may serve?** An SVG under [`MAX_BYTES`], with no script,
/// no event attribute inside a tag and no `javascript:` URL. The reason, when not.
pub fn check_svg(body: &[u8]) -> Result<(), String> {
    if body.len() >= MAX_BYTES {
        return Err(format!("{} bytes, over the {MAX_BYTES}-byte limit", body.len()));
    }
    let text = std::str::from_utf8(body).map_err(|_| "not UTF-8 text".to_string())?;
    let lower = text.to_ascii_lowercase();
    let start = lower.trim_start();
    let root = if start.starts_with("<?xml") { start.find("?>").map(|i| start[i + 2..].trim_start()).unwrap_or("") } else { start };
    if !root.starts_with("<svg") || !lower.contains("</svg>") {
        return Err("not an SVG document".into());
    }
    if lower.contains("<script") {
        return Err("it carries a <script>".into());
    }
    if lower.contains("javascript:") {
        return Err("it carries a javascript: URL".into());
    }
    if let Some(attr) = event_attribute(&lower) {
        return Err(format!("it carries the event attribute `{attr}`"));
    }
    Ok(())
}

/// The first `on…=` attribute name inside a tag, if any (text between tags is not read).
fn event_attribute(lower: &str) -> Option<String> {
    let b = lower.as_bytes();
    let mut in_tag = false;
    let mut quote: Option<u8> = None;
    let mut i = 0;
    while i < b.len() {
        let c = b[i];
        match (in_tag, quote) {
            (false, _) => in_tag = c == b'<',
            (true, Some(q)) => {
                if c == q {
                    quote = None;
                }
            }
            (true, None) => match c {
                b'>' => in_tag = false,
                b'"' | b'\'' => quote = Some(c),
                c if (c.is_ascii_whitespace() || c == b'/') && b[i + 1..].starts_with(b"on") => {
                    let name: String = b[i + 1..].iter().take_while(|x| x.is_ascii_alphanumeric() || **x == b'-' || **x == b':').map(|x| *x as char).collect();
                    let rest = &b[i + 1 + name.len()..];
                    let eq = rest.iter().find(|x| !x.is_ascii_whitespace()) == Some(&b'=');
                    if name.len() > 2 && eq {
                        return Some(name);
                    }
                }
                _ => {}
            },
        }
        i += 1;
    }
    None
}

/// The project id `pairs` (`<repo>=<digits>`) name for `repo`.
pub fn project_for(pairs: &[String], repo: &str) -> Option<String> {
    pairs.iter().filter_map(|p| p.split_once('=')).find(|(r, _)| *r == repo).map(|(_, id)| id.to_string()).filter(|id| !id.is_empty() && id.bytes().all(|c| c.is_ascii_digit()))
}

/// The copy's path under the run's output: `publish/badges/openssf-<repo>.svg`.
pub fn file_for(repo: &str) -> String {
    format!("publish/badges/openssf-{repo}.svg")
}

/// Fetch `url` (at most [`MAX_BYTES`] + 1 read, so an oversized answer is seen as one).
fn fetch(url: &str) -> Result<Vec<u8>, String> {
    let mut answer = ureq::get(url)
        .header("User-Agent", "dwarves-ci (nordisk/ci-status badge copy)")
        .call()
        .map_err(|e| format!("GET {url}: {e}"))?;
    answer.body_mut().with_config().limit(MAX_BYTES as u64 + 1).read_to_vec().map_err(|e| format!("GET {url}: {e}"))
}

/// The verb.
pub fn verb(args: &[String]) -> i32 {
    let usage = "usage: ci-verbs openssf-badge --badge <repo>=<project>... [--checkout <dir>] [--out <dir>] [--base <url>]";
    let checkout = crate::value(args, "--checkout").or_else(|| std::env::var("DWARVES_CI_CHECKOUT").ok().filter(|v| !v.is_empty())).map(PathBuf::from);
    let repo = checkout
        .as_ref()
        .map(|c| c.canonicalize().unwrap_or_else(|_| c.clone()))
        .and_then(|c| c.file_name().map(|n| n.to_string_lossy().into_owned()))
        .unwrap_or_default();
    let Some(project) = project_for(&crate::values(args, "--badge"), &repo) else {
        eprintln!("ci-verbs openssf-badge: no --badge names the checkout `{repo}`\n{usage}");
        return 2;
    };
    let out = crate::value(args, "--out")
        .or_else(|| std::env::var("NORNIR_CI_OUT").ok().filter(|v| !v.is_empty()))
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("."));
    let base = crate::value(args, "--base").unwrap_or_else(|| DEFAULT_BASE.to_string());
    let url = format!("{}/projects/{project}/badge", base.trim_end_matches('/'));
    let body = match fetch(&url) {
        Ok(b) => b,
        Err(e) => {
            eprintln!("ci-verbs openssf-badge: {e}");
            return 1;
        }
    };
    if let Err(why) = check_svg(&body) {
        eprintln!("ci-verbs openssf-badge: {url} is refused: {why}");
        return 1;
    }
    let path = out.join(file_for(&repo));
    let written = path.parent().map_or(Ok(()), std::fs::create_dir_all).and_then(|()| std::fs::write(&path, &body));
    match written {
        Ok(()) => {
            println!("ci-verbs openssf-badge: {repo} project {project}, {} bytes → {}", body.len(), path.display());
            0
        }
        Err(e) => {
            eprintln!("ci-verbs openssf-badge: {}: {e}", path.display());
            1
        }
    }
}