//! Page: the CI status badge, `GET /{owner}/{repo}/badge.svg`. It shows the state of the newest run //! on a branch (`?branch=`, default: the default branch), or of one job of that run (`&job=`). The //! SVG is a flat shields-style image, rendered here with no outside assets. //! //! Access is the repo's read access: a private repo a viewer cannot read is a 404, like every other //! repo page. A repo with no run on the branch still answers 200 with "no status". use forge_core::ci::{JobState, RunState}; use http::header::{CACHE_CONTROL, CONTENT_TYPE}; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::request::uri; use topcoat::router::response::Response; use topcoat::router::{Body, HeaderValue, StatusCode, path_param, route}; use crate::pages::repo_ctx::load_repo; use crate::state::AppState; path_param!(owner); path_param!(repo); /// Longest label shown, in characters. A longer job name is cut here. const MAX_LABEL_CHARS: usize = 64; /// Height of the badge in pixels. const HEIGHT: u32 = 20; /// Padding on each side of a text, in pixels. const PAD: u32 = 5; /// Label half colour and the grey for "no status". const LABEL_COLOR: &str = "#555"; const GREY: &str = "#9f9f9f"; /// What the right-hand half of the badge says. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum BadgeStatus { Passing, Failing, Error, Running, Pending, Skipped, NoStatus, } impl BadgeStatus { /// The word shown on the badge. pub fn text(self) -> &'static str { match self { BadgeStatus::Passing => "passing", BadgeStatus::Failing => "failing", BadgeStatus::Error => "error", BadgeStatus::Running => "running", BadgeStatus::Pending => "pending", BadgeStatus::Skipped => "skipped", BadgeStatus::NoStatus => "no status", } } /// The fill of the right-hand half. pub fn color(self) -> &'static str { match self { BadgeStatus::Passing => "#4c1", BadgeStatus::Failing => "#e05d44", BadgeStatus::Error => "#9a5b5b", BadgeStatus::Running | BadgeStatus::Pending => "#dfb317", BadgeStatus::Skipped | BadgeStatus::NoStatus => GREY, } } } /// The badge status of a run. Waiting for approval shows as pending. pub fn status_of_run(state: RunState) -> BadgeStatus { match state { RunState::Success => BadgeStatus::Passing, RunState::Failure => BadgeStatus::Failing, RunState::Error | RunState::Cancelled => BadgeStatus::Error, RunState::Queued | RunState::Running => BadgeStatus::Running, RunState::WaitingApproval => BadgeStatus::Pending, } } /// The badge status of one job. pub fn status_of_job(state: JobState) -> BadgeStatus { match state { JobState::Success => BadgeStatus::Passing, JobState::Failure => BadgeStatus::Failing, JobState::Error | JobState::Cancelled => BadgeStatus::Error, JobState::Blocked | JobState::Queued | JobState::Running => BadgeStatus::Running, JobState::Skipped => BadgeStatus::Skipped, } } /// Escapes text for XML element content and attribute values. Control characters, which XML /// does not allow, become spaces. pub fn escape_xml(text: &str) -> String { let mut out = String::with_capacity(text.len()); for c in text.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), c if c.is_control() => out.push(' '), c => out.push(c), } } out } /// Width of a text box in pixels: about 6.5 px per character at 11 px Verdana, plus padding. fn text_box(text: &str) -> u32 { let chars = u32::try_from(text.chars().count()).unwrap_or(u32::MAX); chars.saturating_mul(65) / 10 + 2 * PAD } /// Renders the flat badge: a grey label half and a coloured status half. pub fn render_badge(label: &str, status: BadgeStatus) -> String { let label: String = label.chars().take(MAX_LABEL_CHARS).collect(); let status_text = status.text(); let label_w = text_box(&label); let status_w = text_box(status_text); let total = label_w + status_w; let label_x = label_w / 2; let status_x = label_w + status_w / 2; let label = escape_xml(&label); let aria = format!("{label}: {status_text}"); let aria = escape_xml(&aria); let status_color = status.color(); format!( concat!( r##""##, r##"{aria}"##, r##""##, r##""##, r##""##, r##""##, r##""##, r##""##, r##""##, r##""##, r##"{label}"##, r##"{label}"##, r##"{status_text}"##, r##"{status_text}"##, r##""##, ), total = total, h = HEIGHT, aria = aria, label_w = label_w, status_w = status_w, label_color = LABEL_COLOR, status_color = status_color, label_x = label_x, status_x = status_x, label = label, status_text = status_text, ) } /// The query values the badge reads. Empty values count as absent. #[derive(Default)] struct BadgeQuery { branch: Option, job: Option, } fn badge_query(cx: &Cx) -> BadgeQuery { let mut query = BadgeQuery::default(); let Some(raw) = uri(cx).query() else { return query; }; for (key, value) in form_urlencoded::parse(raw.as_bytes()) { let slot = match key.as_ref() { "branch" => &mut query.branch, "job" => &mut query.job, _ => continue, }; if !value.is_empty() { *slot = Some(value.into_owned()); } } query } /// `GET /{owner}/{repo}/badge.svg`. The repo lookup and read check are the ones every repo page /// uses, so a private repo the viewer cannot read is a 404. #[route(GET "/{owner}/{repo}/badge.svg")] async fn ci_badge(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let state = app_context::(cx); let query = badge_query(cx); let branch = query .branch .unwrap_or_else(|| ctx.repo.default_branch.clone()); let git_ref = format!("refs/heads/{branch}"); let run = state .store .latest_run_on_ref(&ctx.repo.id, &git_ref) .await?; let (label, status) = match (run, query.job) { (None, job) => ( job.unwrap_or_else(|| "ci".to_owned()), BadgeStatus::NoStatus, ), (Some(run), None) => ("ci".to_owned(), status_of_run(run.state)), (Some(run), Some(job)) => { let jobs = state.store.jobs_for_run(&run.id).await?; let status = jobs .iter() .find(|j| j.name == job) .map_or(BadgeStatus::NoStatus, |j| status_of_job(j.state)); (job, status) } }; Ok(svg_response(render_badge(&label, status))) } /// The SVG response. The security headers come from the app-wide layer. fn svg_response(body: String) -> Response { let mut res = Response::new(Body::from(body)); *res.status_mut() = StatusCode::OK; let headers = res.headers_mut(); headers.insert(CONTENT_TYPE, HeaderValue::from_static("image/svg+xml")); headers.insert( CACHE_CONTROL, HeaderValue::from_static("no-cache, max-age=0"), ); res } #[cfg(test)] mod tests { use super::*; #[test] fn escapes_markup_and_quotes() { assert_eq!( escape_xml(r#"'&'"#), "<a href="x">'&'</a>" ); assert_eq!(escape_xml("a\u{1}b"), "a b"); } #[test] fn hostile_label_never_reaches_the_markup_raw() { let svg = render_badge("\"", BadgeStatus::Passing); assert!(!svg.contains("{word}")), "{word}"); assert!(svg.contains("fill=\"#555\"")); assert!(svg.starts_with(" text_box("ok")); let svg = render_badge("ci", BadgeStatus::Passing); assert!(svg.contains(&format!( "width=\"{}\"", text_box("ci") + text_box("passing") ))); } }