Skip to main content

max / makenotwork

5.8 KB · 159 lines History Blame Raw
1 //! The `pom versions` roll-up: what every target is running, and how far each
2 //! live build is behind the checkout it came from.
3 //!
4 //! Everything but the last column is a read over the health checks already
5 //! collected: the latest snapshot per target carries the version, the commit,
6 //! and when it was seen. The commits-behind column is the one measurement taken
7 //! here, and it is deliberately local. `git rev-list --count <sha>..HEAD` runs
8 //! against a checkout on the machine invoking the command, so the answer is
9 //! "behind what I have", and a host without the repo gets a blank column rather
10 //! than an error.
11 //!
12 //! A blank is never silent about being a failure: anything that went wrong
13 //! taking the count lands in `behind_error` and is printed beside the row.
14
15 use tokio::process::Command;
16
17 use crate::config::{Config, RepoConfig};
18 use crate::db;
19 use crate::error::Result;
20 use crate::types::VersionRow;
21
22 /// Build the roll-up for every configured target, ordered by target name.
23 pub async fn collect(pool: &sqlx::SqlitePool, config: &Config) -> Result<Vec<VersionRow>> {
24 let mut rows = Vec::new();
25
26 for name in config.target_names() {
27 let target = config.get_target(&name).unwrap();
28 let health = db::get_latest_health(pool, &name).await?;
29 let details = health.as_ref().and_then(|h| h.details.as_ref());
30 let git_sha = details.and_then(|d| d.git_sha.clone());
31
32 let (commits_behind, behind_error) = match (&target.repo, &git_sha) {
33 (None, _) => (None, None),
34 // A repo is configured but there is no commit to anchor the count
35 // to. A target that has been checked and did not send one is a gap
36 // on the target; a target that has never been checked has nothing
37 // to say yet, and the blank row already says it.
38 (Some(_), None) => (
39 None,
40 health
41 .as_ref()
42 .map(|_| "target reports no git_sha in its health body".to_string()),
43 ),
44 (Some(repo), Some(sha)) => match commits_behind(repo, sha).await {
45 Ok(n) => (Some(n), None),
46 Err(e) => (None, Some(e)),
47 },
48 };
49
50 let version = details.and_then(|d| d.version.clone());
51 let version_since = match &version {
52 Some(v) => db::get_version_first_seen(pool, &name, v).await?,
53 None => None,
54 };
55
56 rows.push(VersionRow {
57 target: name,
58 label: target.label.clone(),
59 version,
60 git_sha,
61 checked_at: health.as_ref().map(|h| h.checked_at.clone()),
62 version_since,
63 commits_behind,
64 behind_error,
65 });
66 }
67
68 Ok(rows)
69 }
70
71 /// Count the commits between `sha` and the checkout's HEAD, scoped to the
72 /// repo's subdir when it has one.
73 ///
74 /// The sha comes off a monitored target's health endpoint, so it is untrusted
75 /// input to a subprocess argument list. It is validated as a hex object name
76 /// before it goes anywhere near git: without that, a value like `--upload-pack=`
77 /// or `-C/some/path` would be read by git as an option rather than a revision.
78 /// There is no shell here to escape into, but the option-injection surface is
79 /// real regardless.
80 async fn commits_behind(repo: &RepoConfig, sha: &str) -> std::result::Result<i64, String> {
81 if !is_object_name(sha) {
82 return Err(format!("target reported an unusable git_sha: {sha:?}"));
83 }
84
85 let mut cmd = Command::new("git");
86 cmd.arg("-C").arg(&repo.path);
87 cmd.args(["rev-list", "--count", &format!("{sha}..HEAD")]);
88 if let Some(subdir) = &repo.subdir {
89 cmd.arg("--").arg(subdir);
90 }
91
92 let output = cmd
93 .output()
94 .await
95 .map_err(|e| format!("could not run git in {}: {e}", repo.path.display()))?;
96
97 if !output.status.success() {
98 // The common cases are a missing checkout and a sha the local repo has
99 // never fetched. Both are ordinary on a host that is not the build box,
100 // so they read as a blank column plus this note, not as a hard failure.
101 let stderr = String::from_utf8_lossy(&output.stderr);
102 return Err(format!(
103 "git rev-list exited {}: {}",
104 output.status.code().unwrap_or(-1),
105 stderr.trim()
106 ));
107 }
108
109 parse_count(&String::from_utf8_lossy(&output.stdout))
110 }
111
112 /// Parse `git rev-list --count` output: a single decimal number on one line.
113 fn parse_count(stdout: &str) -> std::result::Result<i64, String> {
114 let trimmed = stdout.trim();
115 trimmed
116 .parse::<i64>()
117 .map_err(|_| format!("unparseable rev-list count: {trimmed:?}"))
118 }
119
120 /// Whether a string is safe to hand to git as a revision: a hex object name,
121 /// full or abbreviated. Deliberately narrow, since the only thing that belongs
122 /// in the health body's `git_sha` is a commit id.
123 fn is_object_name(s: &str) -> bool {
124 (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit())
125 }
126
127 #[cfg(test)]
128 mod tests {
129 use super::*;
130
131 #[test]
132 fn parses_a_count() {
133 assert_eq!(parse_count("8\n").unwrap(), 8);
134 assert_eq!(parse_count("0").unwrap(), 0);
135 }
136
137 #[test]
138 fn rejects_non_numeric_output() {
139 assert!(parse_count("").is_err());
140 assert!(parse_count("fatal: bad revision\n").is_err());
141 }
142
143 #[test]
144 fn accepts_full_and_abbreviated_shas() {
145 assert!(is_object_name("6402bf4e"));
146 assert!(is_object_name("6402bf4e0000000000000000000000000000abcd"));
147 }
148
149 #[test]
150 fn rejects_anything_git_could_read_as_an_option() {
151 assert!(!is_object_name("--upload-pack=touch /tmp/pwned"));
152 assert!(!is_object_name("-C/etc"));
153 assert!(!is_object_name("main"));
154 assert!(!is_object_name("6402bf4e; rm -rf /"));
155 assert!(!is_object_name("abc"));
156 assert!(!is_object_name(""));
157 }
158 }
159