1use crate::ports::diagnostics::{
4 CheckCategory, CheckResult, ConfigIssue, CryptoDiagnosticProvider, DiagnosticError,
5 DiagnosticReport, GitDiagnosticProvider,
6};
7
8pub const MIN_GIT_VERSION: (u32, u32, u32) = (2, 34, 0);
10
11pub const MIN_SSH_VERSION: (u32, u32, u32) = (8, 2, 0);
13
14pub fn parse_git_version(version_str: &str) -> Option<(u32, u32, u32)> {
25 let version_part = version_str
26 .split_whitespace()
27 .find(|s| s.chars().next().is_some_and(|c| c.is_ascii_digit()))?;
28
29 let numbers: Vec<u32> = version_part
30 .split('.')
31 .take(3)
32 .filter_map(|s| {
33 s.chars()
34 .take_while(|c| c.is_ascii_digit())
35 .collect::<String>()
36 .parse()
37 .ok()
38 })
39 .collect();
40
41 match numbers.as_slice() {
42 [major, minor, patch, ..] => Some((*major, *minor, *patch)),
43 [major, minor] => Some((*major, *minor, 0)),
44 [major] => Some((*major, 0, 0)),
45 _ => None,
46 }
47}
48
49pub fn parse_ssh_version(version_str: &str) -> Option<(u32, u32, u32)> {
64 let ssh_part = version_str.split(',').next()?.trim();
66
67 let version_segment = ssh_part.rsplit('_').next()?;
69
70 let mut parts = version_segment.split('.');
72 let major: u32 = parts.next()?.parse().ok()?;
73
74 let minor_patch = parts.next().unwrap_or("0");
75 let mut mp = minor_patch.splitn(2, 'p');
77 let minor: u32 = mp.next()?.parse().ok()?;
78 let patch: u32 = mp
79 .next()
80 .and_then(|p| {
81 p.chars()
82 .take_while(|c| c.is_ascii_digit())
83 .collect::<String>()
84 .parse()
85 .ok()
86 })
87 .unwrap_or(0);
88
89 Some((major, minor, patch))
90}
91
92pub fn check_ssh_version_minimum(version: (u32, u32, u32)) -> bool {
103 version >= MIN_SSH_VERSION
104}
105
106pub struct DiagnosticsWorkflow<G: GitDiagnosticProvider, C: CryptoDiagnosticProvider> {
118 git: G,
119 crypto: C,
120}
121
122impl<G: GitDiagnosticProvider, C: CryptoDiagnosticProvider> DiagnosticsWorkflow<G, C> {
123 pub fn new(git: G, crypto: C) -> Self {
125 Self { git, crypto }
126 }
127
128 pub fn available_checks() -> &'static [&'static str] {
130 &[
131 "git_version",
132 "git_version_minimum",
133 "ssh_keygen",
134 "ssh_version",
135 "git_signing_config",
136 "git_user_config",
137 ]
138 }
139
140 pub fn run_single(&self, name: &str) -> Result<CheckResult, DiagnosticError> {
144 match name {
145 "git_version" => self.git.check_git_version(),
146 "git_version_minimum" => {
147 let git_check = self.git.check_git_version()?;
148 let mut checks = Vec::new();
149 self.check_git_version_minimum(&git_check, &mut checks);
150 checks
151 .into_iter()
152 .next()
153 .ok_or_else(|| DiagnosticError::CheckNotFound(name.to_string()))
154 }
155 "ssh_keygen" => self.crypto.check_ssh_keygen_available(),
156 "ssh_version" => {
157 let mut checks = Vec::new();
158 self.check_ssh_version_minimum(&mut checks);
159 checks
160 .into_iter()
161 .next()
162 .ok_or_else(|| DiagnosticError::CheckNotFound(name.to_string()))
163 }
164 "git_signing_config" => {
165 let mut checks = Vec::new();
166 self.check_git_signing_config(&mut checks)?;
167 checks
168 .into_iter()
169 .next()
170 .ok_or_else(|| DiagnosticError::CheckNotFound(name.to_string()))
171 }
172 "git_user_config" => {
173 let mut checks = Vec::new();
174 self.check_git_user_config(&mut checks)?;
175 checks
176 .into_iter()
177 .next()
178 .ok_or_else(|| DiagnosticError::CheckNotFound(name.to_string()))
179 }
180 _ => Err(DiagnosticError::CheckNotFound(name.to_string())),
181 }
182 }
183
184 pub fn run(&self) -> Result<DiagnosticReport, DiagnosticError> {
192 let mut checks = Vec::new();
193
194 let git_check = self.git.check_git_version()?;
195 self.check_git_version_minimum(&git_check, &mut checks);
196 checks.push(git_check);
197
198 checks.push(self.crypto.check_ssh_keygen_available()?);
199 self.check_ssh_version_minimum(&mut checks);
200
201 self.check_git_user_config(&mut checks)?;
202 self.check_git_signing_config(&mut checks)?;
203
204 Ok(DiagnosticReport { checks })
205 }
206
207 fn check_git_version_minimum(&self, git_check: &CheckResult, checks: &mut Vec<CheckResult>) {
208 let version_str = git_check.message.as_deref().unwrap_or("");
209 match parse_git_version(version_str) {
210 Some(version) if version >= MIN_GIT_VERSION => {
211 checks.push(CheckResult {
212 name: "Git version".to_string(),
213 passed: true,
214 message: Some(format!(
215 "{}.{}.{} (>= {}.{}.{})",
216 version.0,
217 version.1,
218 version.2,
219 MIN_GIT_VERSION.0,
220 MIN_GIT_VERSION.1,
221 MIN_GIT_VERSION.2,
222 )),
223 config_issues: vec![],
224 category: CheckCategory::Critical,
225 });
226 }
227 Some(version) => {
228 checks.push(CheckResult {
229 name: "Git version".to_string(),
230 passed: false,
231 message: Some(format!(
232 "{}.{}.{} found, need >= {}.{}.{} for SSH signing",
233 version.0,
234 version.1,
235 version.2,
236 MIN_GIT_VERSION.0,
237 MIN_GIT_VERSION.1,
238 MIN_GIT_VERSION.2,
239 )),
240 config_issues: vec![],
241 category: CheckCategory::Critical,
242 });
243 }
244 None => {
245 if !git_check.passed {
246 return;
247 }
248 checks.push(CheckResult {
249 name: "Git version".to_string(),
250 passed: false,
251 message: Some(format!("Could not parse version from: {version_str}")),
252 config_issues: vec![],
253 category: CheckCategory::Advisory,
254 });
255 }
256 }
257 }
258
259 fn check_ssh_version_minimum(&self, checks: &mut Vec<CheckResult>) {
260 let version_str = match self.crypto.check_ssh_version() {
261 Ok(v) => v,
262 Err(_) => return,
263 };
264
265 if version_str == "unknown" {
266 return;
267 }
268
269 match parse_ssh_version(&version_str) {
270 Some(version) if check_ssh_version_minimum(version) => {
271 checks.push(CheckResult {
272 name: "SSH version".to_string(),
273 passed: true,
274 message: Some(format!(
275 "{}.{}.{} (>= {}.{}.{})",
276 version.0,
277 version.1,
278 version.2,
279 MIN_SSH_VERSION.0,
280 MIN_SSH_VERSION.1,
281 MIN_SSH_VERSION.2,
282 )),
283 config_issues: vec![],
284 category: CheckCategory::Advisory,
285 });
286 }
287 Some(version) => {
288 checks.push(CheckResult {
289 name: "SSH version".to_string(),
290 passed: false,
291 message: Some(format!(
292 "{}.{}.{} found, need >= {}.{}.{} for -Y find-principals",
293 version.0,
294 version.1,
295 version.2,
296 MIN_SSH_VERSION.0,
297 MIN_SSH_VERSION.1,
298 MIN_SSH_VERSION.2,
299 )),
300 config_issues: vec![],
301 category: CheckCategory::Advisory,
302 });
303 }
304 None => {
305 checks.push(CheckResult {
306 name: "SSH version".to_string(),
307 passed: false,
308 message: Some(format!("Could not parse version from: {version_str}")),
309 config_issues: vec![],
310 category: CheckCategory::Advisory,
311 });
312 }
313 }
314 }
315
316 fn check_git_user_config(&self, checks: &mut Vec<CheckResult>) -> Result<(), DiagnosticError> {
317 let name = self.git.get_git_config("user.name")?;
318 let email = self.git.get_git_config("user.email")?;
319
320 let mut issues: Vec<ConfigIssue> = Vec::new();
321 if name.is_none() {
322 issues.push(ConfigIssue::Absent("user.name".to_string()));
323 }
324 if email.is_none() {
325 issues.push(ConfigIssue::Absent("user.email".to_string()));
326 }
327
328 let passed = issues.is_empty();
329 let message = if passed {
330 Some(format!(
331 "{} <{}>",
332 name.unwrap_or_default(),
333 email.unwrap_or_default()
334 ))
335 } else {
336 None
337 };
338
339 checks.push(CheckResult {
340 name: "Git user identity".to_string(),
341 passed,
342 message,
343 config_issues: issues,
344 category: CheckCategory::Advisory,
345 });
346
347 Ok(())
348 }
349
350 fn check_git_signing_config(
351 &self,
352 checks: &mut Vec<CheckResult>,
353 ) -> Result<(), DiagnosticError> {
354 let required = [
355 ("gpg.format", "ssh"),
356 ("commit.gpgsign", "true"),
357 ("tag.gpgsign", "true"),
358 ];
359 let presence_only = ["user.signingkey", "gpg.ssh.program"];
360
361 let mut issues: Vec<ConfigIssue> = Vec::new();
362
363 for (key, expected) in &required {
364 match self.git.get_git_config(key)? {
365 Some(val) if val == *expected => {}
366 Some(actual) => {
367 issues.push(ConfigIssue::Mismatch {
368 key: key.to_string(),
369 expected: expected.to_string(),
370 actual,
371 });
372 }
373 None => {
374 issues.push(ConfigIssue::Absent(key.to_string()));
375 }
376 }
377 }
378
379 for key in &presence_only {
380 if self.git.get_git_config(key)?.is_none() {
381 issues.push(ConfigIssue::Absent(key.to_string()));
382 }
383 }
384
385 let passed = issues.is_empty();
386
387 checks.push(CheckResult {
388 name: "Git signing config".to_string(),
389 passed,
390 message: None,
391 config_issues: issues,
392 category: CheckCategory::Critical,
393 });
394
395 Ok(())
396 }
397}