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