//! The `pom versions` roll-up: what every target is running, and how far each //! live build is behind the checkout it came from. //! //! Everything but the last column is a read over the health checks already //! collected: the latest snapshot per target carries the version, the commit, //! and when it was seen. The commits-behind column is the one measurement taken //! here, and it is deliberately local. `git rev-list --count ..HEAD` runs //! against a checkout on the machine invoking the command, so the answer is //! "behind what I have", and a host without the repo gets a blank column rather //! than an error. //! //! A blank is never silent about being a failure: anything that went wrong //! taking the count lands in `behind_error` and is printed beside the row. use tokio::process::Command; use crate::config::{Config, RepoConfig}; use crate::db; use crate::error::Result; use crate::types::VersionRow; /// Build the roll-up for every configured target, ordered by target name. pub async fn collect(pool: &sqlx::SqlitePool, config: &Config) -> Result> { let mut rows = Vec::new(); for name in config.target_names() { let target = config.get_target(&name).unwrap(); let health = db::get_latest_health(pool, &name).await?; let details = health.as_ref().and_then(|h| h.details.as_ref()); let git_sha = details.and_then(|d| d.git_sha.clone()); let (commits_behind, behind_error) = match (&target.repo, &git_sha) { (None, _) => (None, None), // A repo is configured but there is no commit to anchor the count // to. A target that has been checked and did not send one is a gap // on the target; a target that has never been checked has nothing // to say yet, and the blank row already says it. (Some(_), None) => ( None, health .as_ref() .map(|_| "target reports no git_sha in its health body".to_string()), ), (Some(repo), Some(sha)) => match commits_behind(repo, sha).await { Ok(n) => (Some(n), None), Err(e) => (None, Some(e)), }, }; let version = details.and_then(|d| d.version.clone()); let version_since = match &version { Some(v) => db::get_version_first_seen(pool, &name, v).await?, None => None, }; rows.push(VersionRow { target: name, label: target.label.clone(), version, git_sha, checked_at: health.as_ref().map(|h| h.checked_at.clone()), version_since, commits_behind, behind_error, }); } Ok(rows) } /// Count the commits between `sha` and the checkout's HEAD, scoped to the /// repo's subdir when it has one. /// /// The sha comes off a monitored target's health endpoint, so it is untrusted /// input to a subprocess argument list. It is validated as a hex object name /// before it goes anywhere near git: without that, a value like `--upload-pack=` /// or `-C/some/path` would be read by git as an option rather than a revision. /// There is no shell here to escape into, but the option-injection surface is /// real regardless. async fn commits_behind(repo: &RepoConfig, sha: &str) -> std::result::Result { if !is_object_name(sha) { return Err(format!("target reported an unusable git_sha: {sha:?}")); } let mut cmd = Command::new("git"); cmd.arg("-C").arg(&repo.path); cmd.args(["rev-list", "--count", &format!("{sha}..HEAD")]); if let Some(subdir) = &repo.subdir { cmd.arg("--").arg(subdir); } let output = cmd .output() .await .map_err(|e| format!("could not run git in {}: {e}", repo.path.display()))?; if !output.status.success() { // The common cases are a missing checkout and a sha the local repo has // never fetched. Both are ordinary on a host that is not the build box, // so they read as a blank column plus this note, not as a hard failure. let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!( "git rev-list exited {}: {}", output.status.code().unwrap_or(-1), stderr.trim() )); } parse_count(&String::from_utf8_lossy(&output.stdout)) } /// Parse `git rev-list --count` output: a single decimal number on one line. fn parse_count(stdout: &str) -> std::result::Result { let trimmed = stdout.trim(); trimmed .parse::() .map_err(|_| format!("unparseable rev-list count: {trimmed:?}")) } /// Whether a string is safe to hand to git as a revision: a hex object name, /// full or abbreviated. Deliberately narrow, since the only thing that belongs /// in the health body's `git_sha` is a commit id. fn is_object_name(s: &str) -> bool { (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit()) } #[cfg(test)] mod tests { use super::*; #[test] fn parses_a_count() { assert_eq!(parse_count("8\n").unwrap(), 8); assert_eq!(parse_count("0").unwrap(), 0); } #[test] fn rejects_non_numeric_output() { assert!(parse_count("").is_err()); assert!(parse_count("fatal: bad revision\n").is_err()); } #[test] fn accepts_full_and_abbreviated_shas() { assert!(is_object_name("6402bf4e")); assert!(is_object_name("6402bf4e0000000000000000000000000000abcd")); } #[test] fn rejects_anything_git_could_read_as_an_option() { assert!(!is_object_name("--upload-pack=touch /tmp/pwned")); assert!(!is_object_name("-C/etc")); assert!(!is_object_name("main")); assert!(!is_object_name("6402bf4e; rm -rf /")); assert!(!is_object_name("abc")); assert!(!is_object_name("")); } }