Skip to main content

auths_sdk/workflows/
diagnostics.rs

1//! Diagnostics workflow — orchestrates system health checks via injected providers.
2
3use crate::ports::diagnostics::{
4    CheckCategory, CheckResult, ConfigIssue, CryptoDiagnosticProvider, DiagnosticError,
5    DiagnosticReport, GitDiagnosticProvider,
6};
7
8/// Minimum Git version required for SSH signing support.
9pub const MIN_GIT_VERSION: (u32, u32, u32) = (2, 34, 0);
10
11/// Minimum OpenSSH version required (`-Y find-principals` was added in 8.2).
12pub const MIN_SSH_VERSION: (u32, u32, u32) = (8, 2, 0);
13
14/// Parses a Git version string into a `(major, minor, patch)` tuple.
15///
16/// Args:
17/// * `version_str`: Raw output from `git --version`, e.g. `"git version 2.39.0"`.
18///
19/// Usage:
20/// ```ignore
21/// let v = parse_git_version("git version 2.39.0");
22/// assert_eq!(v, Some((2, 39, 0)));
23/// ```
24pub 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
49/// Parses an OpenSSH version string into a `(major, minor, patch)` tuple.
50///
51/// Handles formats from `ssh -V` (written to stderr):
52/// - `OpenSSH_9.6p1, LibreSSL 3.3.6` (macOS/Linux)
53/// - `OpenSSH_for_Windows_8.6p1, LibreSSL 3.4.3` (Windows)
54///
55/// Args:
56/// * `version_str`: Raw stderr output from `ssh -V`.
57///
58/// Usage:
59/// ```ignore
60/// let v = parse_ssh_version("OpenSSH_9.6p1, LibreSSL 3.3.6");
61/// assert_eq!(v, Some((9, 6, 1)));
62/// ```
63pub fn parse_ssh_version(version_str: &str) -> Option<(u32, u32, u32)> {
64    // Find the "OpenSSH_X.Yp1" or "OpenSSH_for_Windows_X.Yp1" portion
65    let ssh_part = version_str.split(',').next()?.trim();
66
67    // Extract the version after the last underscore
68    let version_segment = ssh_part.rsplit('_').next()?;
69
70    // Split on '.' to get major, then minor+patch
71    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    // minor_patch is like "6p1" or "6" — split on 'p' for patch
76    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
92/// Check whether the given SSH version meets the minimum requirement.
93///
94/// Args:
95/// * `version`: Parsed `(major, minor, patch)` tuple.
96///
97/// Usage:
98/// ```ignore
99/// assert!(check_ssh_version_minimum((9, 6, 1)));
100/// assert!(!check_ssh_version_minimum((7, 9, 0)));
101/// ```
102pub fn check_ssh_version_minimum(version: (u32, u32, u32)) -> bool {
103    version >= MIN_SSH_VERSION
104}
105
106/// Orchestrates diagnostic checks without subprocess calls.
107///
108/// Args:
109/// * `G`: A [`GitDiagnosticProvider`] implementation.
110/// * `C`: A [`CryptoDiagnosticProvider`] implementation.
111///
112/// Usage:
113/// ```ignore
114/// let workflow = DiagnosticsWorkflow::new(posix_adapter.clone(), posix_adapter);
115/// let report = workflow.run()?;
116/// ```
117pub struct DiagnosticsWorkflow<G: GitDiagnosticProvider, C: CryptoDiagnosticProvider> {
118    git: G,
119    crypto: C,
120}
121
122impl<G: GitDiagnosticProvider, C: CryptoDiagnosticProvider> DiagnosticsWorkflow<G, C> {
123    /// Create a new diagnostics workflow with the given providers.
124    pub fn new(git: G, crypto: C) -> Self {
125        Self { git, crypto }
126    }
127
128    /// Names of all available checks.
129    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    /// Run a single diagnostic check by name.
141    ///
142    /// Returns `Err(DiagnosticError::CheckNotFound)` if the name is unknown.
143    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    /// Run all diagnostic checks and return the aggregated report.
185    ///
186    /// Usage:
187    /// ```ignore
188    /// let report = workflow.run()?;
189    /// assert!(report.checks.iter().all(|c| c.passed));
190    /// ```
191    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}