1use crate::command::{CommandRequest, CommandRunner};
2use crate::config::Config;
3use crate::config::TargetConfig;
4use crate::git::GitRepository;
5use anyhow::{Context, Result, bail};
6use serde::Deserialize;
7use std::path::Path;
8use std::sync::Arc;
9
10#[derive(Clone, Debug, Eq, PartialEq)]
11pub enum CapabilityStatus {
12 Verified,
13 Unverifiable,
14}
15
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct Capability {
18 pub status: CapabilityStatus,
19 pub description: String,
20}
21
22#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct DoctorReport {
24 pub capabilities: Vec<Capability>,
25 pub actor: String,
26 pub token: Secret,
27}
28
29#[derive(Clone, Eq, PartialEq)]
30pub struct Secret(String);
31
32impl Secret {
33 pub fn new(value: impl Into<String>) -> Self {
34 Self(value.into())
35 }
36
37 pub fn expose(&self) -> &str {
38 &self.0
39 }
40}
41
42impl std::fmt::Debug for Secret {
43 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 formatter.write_str("Secret([REDACTED])")
45 }
46}
47
48#[derive(Deserialize)]
49#[serde(rename_all = "camelCase")]
50struct RepositoryView {
51 name_with_owner: String,
52 viewer_permission: String,
53}
54
55pub fn run_doctor(
56 config: &Config,
57 root: &Path,
58 runner: Arc<dyn CommandRunner>,
59) -> Result<DoctorReport> {
60 check_project_requirements(config, root, Arc::clone(&runner))?;
61 let staging = tempfile::tempdir().context("tool-owned staging directory is not writable")?;
62 std::fs::write(staging.path().join("probe"), b"release-tool doctor\n")
63 .context("tool-owned staging directory is not writable")?;
64 command(runner.as_ref(), root, "git", &["--version"])?;
65 command(runner.as_ref(), root, "gh", &["--version"])?;
66
67 let repository = GitRepository::new(root, Arc::clone(&runner));
68 repository.snapshot(&config.repository.branch)?;
69 let origin = repository.origin_url()?;
70 let identity = github_identity(&origin)
71 .with_context(|| format!("origin is not a GitHub repository URL: {origin}"))?;
72 if identity != config.repository.github {
73 bail!(
74 "configured repository `{}` does not match origin `{identity}`",
75 config.repository.github
76 );
77 }
78
79 command(
80 runner.as_ref(),
81 root,
82 "gh",
83 &["auth", "status", "--hostname", "github.com"],
84 )?;
85 let token = command(
86 runner.as_ref(),
87 root,
88 "gh",
89 &["auth", "token", "--hostname", "github.com"],
90 )?;
91 let token = token.trim().to_owned();
92 if token.is_empty() {
93 bail!("gh returned an empty GitHub token");
94 }
95 let actor = command(
96 runner.as_ref(),
97 root,
98 "gh",
99 &["api", "--hostname", "github.com", "user", "--jq", ".login"],
100 )?;
101 let actor = actor.trim().to_owned();
102 if actor.is_empty() {
103 bail!("gh returned an empty GitHub actor");
104 }
105 let view = command(
106 runner.as_ref(),
107 root,
108 "gh",
109 &[
110 "repo",
111 "view",
112 &config.repository.github,
113 "--json",
114 "nameWithOwner,viewerPermission",
115 ],
116 )?;
117 let view: RepositoryView =
118 serde_json::from_str(&view).context("invalid gh repo view output")?;
119 if view.name_with_owner != config.repository.github {
120 bail!(
121 "gh resolved `{}` instead of configured repository `{}`",
122 view.name_with_owner,
123 config.repository.github
124 );
125 }
126 if !matches!(
127 view.viewer_permission.as_str(),
128 "WRITE" | "MAINTAIN" | "ADMIN"
129 ) {
130 bail!(
131 "GitHub account only has {} permission for {}",
132 view.viewer_permission,
133 config.repository.github
134 );
135 }
136
137 let mut capabilities = vec![
138 Capability {
139 status: CapabilityStatus::Verified,
140 description: "Git repository".to_owned(),
141 },
142 Capability {
143 status: CapabilityStatus::Verified,
144 description: format!("GitHub account: {actor}"),
145 },
146 Capability {
147 status: CapabilityStatus::Verified,
148 description: format!("repository permission: {}", view.viewer_permission),
149 },
150 Capability {
151 status: CapabilityStatus::Verified,
152 description: "tool-owned staging directory".to_owned(),
153 },
154 Capability {
155 status: CapabilityStatus::Unverifiable,
156 description: "destination write permission".to_owned(),
157 },
158 ];
159 if config
160 .targets
161 .iter()
162 .any(|target| matches!(target, TargetConfig::OciImage { .. }))
163 {
164 capabilities.push(Capability {
165 status: CapabilityStatus::Verified,
166 description: "Docker buildx and OCI target commands".to_owned(),
167 });
168 capabilities.push(Capability {
169 status: CapabilityStatus::Unverifiable,
170 description: "OCI registry single-writer or immutable-tag enforcement".to_owned(),
171 });
172 }
173 Ok(DoctorReport {
174 capabilities,
175 actor,
176 token: Secret(token),
177 })
178}
179
180fn check_project_requirements(
181 config: &Config,
182 root: &Path,
183 runner: Arc<dyn CommandRunner>,
184) -> Result<()> {
185 if let Some(program) = config.hooks.preflight.first() {
186 require_command(root, program)?;
187 }
188 let mut needs_oci = false;
189 for target in &config.targets {
190 match target {
191 TargetConfig::DockerArchive {
192 build, local_check, ..
193 } => {
194 require_command(root, &build[0])?;
195 require_command(root, &local_check[0])?;
196 require_command(root, "docker")?;
197 require_command(root, "xz")?;
198 }
199 TargetConfig::MavenReactor {
200 wrapper,
201 pom,
202 projects,
203 remote_check,
204 ..
205 } => {
206 require_command(root, &wrapper.display().to_string())?;
207 let root_pom = root.join(pom);
208 if !root_pom.is_file() {
209 bail!("Maven root POM does not exist: {}", root_pom.display());
210 }
211 for project in projects {
212 let project_pom = root.join(project).join("pom.xml");
213 if !project_pom.is_file() {
214 bail!(
215 "Maven project POM does not exist: {}",
216 project_pom.display()
217 );
218 }
219 }
220 if let Some(program) = remote_check.first() {
221 require_command(root, program)?;
222 }
223 let publisher = config
224 .publishers
225 .get(target.publisher())
226 .context("Maven target publisher disappeared during doctor")?;
227 if let crate::config::PublisherConfig::GithubMaven { settings, .. } = publisher {
228 let settings = root.join(settings);
229 if !settings.is_file() {
230 bail!("Maven settings file does not exist: {}", settings.display());
231 }
232 }
233 }
234 TargetConfig::OciImage {
235 reuse_check, build, ..
236 } => {
237 require_command(root, &reuse_check[0])?;
238 require_command(root, &build[0])?;
239 needs_oci = true;
240 }
241 }
242 }
243 if needs_oci {
244 require_command(root, "docker")?;
245 command(runner.as_ref(), root, "docker", &["buildx", "version"])?;
246 let inspect_help = command(
247 runner.as_ref(),
248 root,
249 "docker",
250 &["buildx", "imagetools", "inspect", "--help"],
251 )?;
252 if !inspect_help.contains("--format") || !inspect_help.contains("--raw") {
253 bail!("docker buildx imagetools inspect does not support --format and --raw");
254 }
255 let create_help = command(
256 runner.as_ref(),
257 root,
258 "docker",
259 &["buildx", "imagetools", "create", "--help"],
260 )?;
261 if !create_help.contains("--prefer-index") {
262 bail!("docker buildx imagetools create does not support --prefer-index");
263 }
264 }
265 Ok(())
266}
267
268fn require_command(root: &Path, program: &str) -> Result<()> {
269 let program_path = Path::new(program);
270 if program_path.components().count() > 1 || program_path.is_absolute() {
271 let path = if program_path.is_absolute() {
272 program_path.to_path_buf()
273 } else {
274 root.join(program_path)
275 };
276 if path.is_file() {
277 return Ok(());
278 }
279 } else if std::env::var_os("PATH")
280 .map(|path| {
281 std::env::split_paths(&path)
282 .map(|directory| directory.join(program))
283 .any(|candidate| candidate.is_file())
284 })
285 .unwrap_or(false)
286 {
287 return Ok(());
288 }
289 bail!("required command `{program}` was not found")
290}
291
292fn command(
293 runner: &dyn CommandRunner,
294 root: &Path,
295 program: &str,
296 arguments: &[&str],
297) -> Result<String> {
298 let request = CommandRequest::new(program, arguments.iter().copied(), root);
299 Ok(runner
300 .execute(&request)?
301 .require_success(&format!("{} {}", program, arguments.join(" ")))?
302 .stdout)
303}
304
305fn github_identity(url: &str) -> Option<String> {
306 let url = url.trim().trim_end_matches(".git");
307 if let Some(identity) = url.strip_prefix("https://github.com/") {
308 return Some(identity.to_owned());
309 }
310 if let Some(identity) = url.strip_prefix("git@github.com:") {
311 return Some(identity.to_owned());
312 }
313 if let Some(identity) = url.strip_prefix("ssh://git@github.com/") {
314 return Some(identity.to_owned());
315 }
316 None
317}