1use std::process::Command;
11
12use serde::Serialize;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
16#[serde(rename_all = "kebab-case")]
17pub enum ProbeClass {
18 Hard,
20 Soft,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
26#[serde(rename_all = "kebab-case")]
27pub enum ProbeStatus {
28 Ok,
30 Failed,
32}
33
34#[derive(Debug, Serialize)]
36pub struct ProbeResult {
37 pub id: &'static str,
39 pub class: ProbeClass,
41 pub status: ProbeStatus,
43 pub message: String,
45 #[serde(skip_serializing_if = "Option::is_none")]
47 pub remediation: Option<String>,
48}
49
50impl ProbeResult {
51 fn ok(id: &'static str, class: ProbeClass, message: impl Into<String>) -> Self {
52 Self {
53 id,
54 class,
55 status: ProbeStatus::Ok,
56 message: message.into(),
57 remediation: None,
58 }
59 }
60
61 fn failed(
62 id: &'static str,
63 class: ProbeClass,
64 message: impl Into<String>,
65 remediation: impl Into<String>,
66 ) -> Self {
67 Self {
68 id,
69 class,
70 status: ProbeStatus::Failed,
71 message: message.into(),
72 remediation: Some(remediation.into()),
73 }
74 }
75}
76
77#[must_use]
79pub fn run_all() -> Vec<ProbeResult> {
80 vec![
81 shell(),
82 state_root(),
83 git_remote(),
84 forge_cli(
85 "gh-auth",
86 "RK_GH_BIN",
87 "gh",
88 "the GitHub CLI",
89 "gh auth login",
90 &[&["auth", "status", "--active"], &["auth", "status"]],
95 ),
96 forge_cli(
97 "glab-auth",
98 "RK_GLAB_BIN",
99 "glab",
100 "the GitLab CLI",
101 "glab auth login",
102 &[&["auth", "status"]],
103 ),
104 tool(
105 "openssl",
106 "RK_OPENSSL_BIN",
107 "openssl",
108 "OpenSSL; install-bot signs the App JWT with it",
109 &["version"],
110 ),
111 tool(
112 "curl",
113 "RK_CURL_BIN",
114 "curl",
115 "curl; install-bot reads the installation and rk versions --check fetches with it",
116 &["--version"],
117 ),
118 tool(
119 "cosign",
120 "RK_COSIGN_BIN",
121 "cosign",
122 "cosign; the release verify step checks a GitLab provenance bundle with it",
123 &["version"],
124 ),
125 tool(
126 "pypi-attestations",
127 "RK_PYPI_ATTESTATIONS_BIN",
128 "pypi-attestations",
129 "pypi-attestations; the release verify step checks a PyPI distribution's attestations with it",
130 &["--help"],
131 ),
132 ]
133}
134
135fn tool(
139 id: &'static str,
140 env_override: &str,
141 default_bin: &str,
142 label: &str,
143 args: &[&str],
144) -> ProbeResult {
145 let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
146 match Command::new(&bin).args(args).output() {
147 Ok(out) if out.status.success() => {
148 ProbeResult::ok(id, ProbeClass::Soft, format!("{default_bin} runs"))
149 }
150 Ok(_) => ProbeResult::failed(
151 id,
152 ProbeClass::Soft,
153 format!("{default_bin} does not answer {}", args.join(" ")),
154 format!("repair {label}"),
155 ),
156 Err(_) => ProbeResult::failed(
157 id,
158 ProbeClass::Soft,
159 format!("{default_bin} is not on PATH"),
160 format!("install {label}"),
161 ),
162 }
163}
164
165fn shell() -> ProbeResult {
167 let id = "sh";
168 match Command::new("sh").args(["-c", "exit 0"]).status() {
169 Ok(status) if status.success() => ProbeResult::ok(id, ProbeClass::Hard, "sh runs"),
170 Ok(status) => ProbeResult::failed(
171 id,
172 ProbeClass::Hard,
173 format!("sh exited {status}"),
174 "repair the POSIX shell on PATH",
175 ),
176 Err(source) => ProbeResult::failed(
177 id,
178 ProbeClass::Hard,
179 format!("sh does not spawn: {source}"),
180 "install a POSIX shell on PATH",
181 ),
182 }
183}
184
185fn state_root() -> ProbeResult {
188 let id = "state-root";
189 let Some(root) = crate::applog::state_root() else {
190 return ProbeResult::failed(
191 id,
192 ProbeClass::Hard,
193 "neither XDG_STATE_HOME nor HOME is set",
194 "export HOME, or XDG_STATE_HOME",
195 );
196 };
197 let display = root.display().to_string();
198 let probe = root.join(format!(".probe-{}", std::process::id()));
199 let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
200 let _ = std::fs::remove_file(&probe);
201 match written {
202 Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{display} is writable")),
203 Err(source) => ProbeResult::failed(
204 id,
205 ProbeClass::Hard,
206 format!("{display} is not writable: {source}"),
207 format!("make {display} writable"),
208 ),
209 }
210}
211
212fn git_remote() -> ProbeResult {
215 let id = "git-remote";
216 let out = Command::new("git")
217 .args(["remote", "get-url", "origin"])
218 .output();
219 let url = match out {
220 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
221 _ => {
222 return ProbeResult::failed(
223 id,
224 ProbeClass::Soft,
225 "the working directory has no origin remote",
226 "pass --repo <owner/name> where a command needs the slug",
227 );
228 }
229 };
230 remote_host(&url).map_or_else(
234 || {
235 ProbeResult::failed(
236 id,
237 ProbeClass::Soft,
238 "the origin remote does not parse to a host",
239 "pass --repo <owner/name> where a command needs the slug",
240 )
241 },
242 |host| ProbeResult::ok(id, ProbeClass::Soft, format!("origin resolves to {host}")),
243 )
244}
245
246fn remote_host(url: &str) -> Option<String> {
248 if let Some(rest) = url.split_once("://").map(|(_, rest)| rest) {
249 let authority = rest.split('/').next()?;
250 let host = authority
251 .rsplit_once('@')
252 .map_or(authority, |(_, host)| host);
253 let host = host.split(':').next()?;
254 return (!host.is_empty()).then(|| host.to_owned());
255 }
256 let (authority, path) = url.split_once(':')?;
257 let host = authority
258 .rsplit_once('@')
259 .map_or(authority, |(_, host)| host);
260 (!host.is_empty() && !path.is_empty()).then(|| host.to_owned())
261}
262
263fn forge_cli(
269 id: &'static str,
270 env_override: &str,
271 default_bin: &str,
272 label: &str,
273 login: &str,
274 attempts: &[&[&str]],
275) -> ProbeResult {
276 let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
277 let mut spawned = false;
278 for args in attempts {
279 match Command::new(&bin).args(*args).output() {
280 Ok(out) if out.status.success() => {
281 return ProbeResult::ok(
282 id,
283 ProbeClass::Soft,
284 format!("{default_bin} is authenticated"),
285 );
286 }
287 Ok(_) => spawned = true,
288 Err(_) => {}
289 }
290 }
291 if spawned {
292 ProbeResult::failed(
293 id,
294 ProbeClass::Soft,
295 format!("{default_bin} is not authenticated"),
296 format!("run {login}"),
297 )
298 } else {
299 ProbeResult::failed(
300 id,
301 ProbeClass::Soft,
302 format!("{default_bin} is not on PATH"),
303 format!("install {label}"),
304 )
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::remote_host;
311
312 #[test]
313 fn a_remote_host_parses_from_both_url_forms() {
314 assert_eq!(
315 remote_host("https://github.com/owner/name.git").as_deref(),
316 Some("github.com")
317 );
318 assert_eq!(
319 remote_host("git@gitlab.com:group/sub/name.git").as_deref(),
320 Some("gitlab.com")
321 );
322 assert_eq!(
323 remote_host("ssh://git@github.com:22/owner/name.git").as_deref(),
324 Some("github.com")
325 );
326 assert_eq!(remote_host("not a url"), None);
327 }
328}