Skip to main content

pacsea/logic/
ssh_setup.rs

1//! SSH setup workflow helpers for AUR voting.
2
3use std::fs;
4use std::fs::OpenOptions;
5use std::io::Write;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8use std::sync::{Arc, Mutex};
9
10/// Fixed host used by AUR SSH voting.
11const AUR_HOST: &str = "aur.archlinux.org";
12/// Fixed SSH key file name for the guided setup flow.
13const AUR_KEY_NAME: &str = "aur_key";
14/// Login URL shown in the setup flow.
15pub const AUR_ACCOUNT_URL: &str = "https://aur.archlinux.org/login";
16
17/// What: Extract the first OpenSSH public key line from setup status lines.
18///
19/// Inputs:
20/// - `status_lines`: Lines produced by `run_aur_ssh_setup` / validation (may include labels and hints).
21///
22/// Output:
23/// - Trimmed key line (for example `ssh-ed25519 AAAA... comment`) when found.
24///
25/// Details:
26/// - Matches lines whose trimmed text starts with `ssh-` (covers `ssh-ed25519`, `ssh-rsa`, etc.).
27#[must_use]
28pub fn ssh_public_key_line_from_status_lines(status_lines: &[String]) -> Option<String> {
29    status_lines.iter().find_map(|line| {
30        let trimmed = line.trim();
31        trimmed.starts_with("ssh-").then(|| trimmed.to_string())
32    })
33}
34
35/// What: Copy the first OpenSSH public key line from setup status lines to the clipboard.
36///
37/// Inputs:
38/// - `status_lines`: Modal status lines that may include a line starting with `ssh-`.
39///
40/// Output:
41/// - `None` when no public key line is present.
42/// - `Some(Ok(()))` when `wl-copy` or `xclip` accepted the key text.
43/// - `Some(Err(message))` when a key line exists but clipboard copy failed.
44///
45/// Details:
46/// - Delegates to `crate::util::clipboard::copy_plain_text_to_clipboard` (no PKGBUILD suffix).
47#[must_use]
48pub fn try_copy_aur_ssh_public_key_from_status_lines(
49    status_lines: &[String],
50) -> Option<Result<(), String>> {
51    let key_line = ssh_public_key_line_from_status_lines(status_lines)?;
52    Some(crate::util::clipboard::copy_plain_text_to_clipboard(
53        &key_line,
54    ))
55}
56
57/// What: Check whether `openssh` is installed on the system.
58///
59/// Inputs: None.
60///
61/// Output:
62/// - `true` when `openssh` is detected as installed.
63///
64/// Details:
65/// - Uses Pacsea installed-package index (`openssh` package name).
66#[must_use]
67pub fn is_openssh_installed() -> bool {
68    #[cfg(test)]
69    if let Ok(v) = std::env::var("PACSEA_TEST_OPENSSH_INSTALLED") {
70        return v == "1";
71    }
72    crate::index::is_installed("openssh")
73}
74
75/// What: Workflow result for attempting SSH setup actions.
76///
77/// Inputs:
78/// - Produced by `run_aur_ssh_setup`.
79///
80/// Output:
81/// - Either a completed report or an overwrite-confirmation request.
82///
83/// Details:
84/// - `NeedsOverwrite` includes the currently detected host block and progress lines.
85pub enum AurSshSetupResult {
86    /// Setup finished (success or failure details are in `report.success` + `report.lines`).
87    Completed(AurSshSetupReport),
88    /// Existing host block requires explicit user overwrite confirmation.
89    NeedsOverwrite {
90        /// Existing host block text from `~/.ssh/config`.
91        existing_block: String,
92        /// Status lines generated before the overwrite decision point.
93        lines: Vec<String>,
94    },
95}
96
97/// What: Final setup report for modal rendering.
98///
99/// Inputs:
100/// - Built by `run_aur_ssh_setup`.
101///
102/// Output:
103/// - `success` flag and human-readable status lines.
104pub struct AurSshSetupReport {
105    /// Whether the full workflow completed successfully.
106    pub success: bool,
107    /// Human-readable step/result lines for UI display.
108    pub lines: Vec<String>,
109}
110
111/// What: Detect whether AUR SSH setup appears configured locally.
112///
113/// Inputs: None.
114///
115/// Output:
116/// - `true` when key exists and `~/.ssh/config` has required AUR host directives.
117///
118/// Details:
119/// - This check is local-only and does not validate remote SSH auth/network.
120#[must_use]
121pub fn is_aur_ssh_setup_configured() -> bool {
122    let Some(home) = home_dir() else {
123        return false;
124    };
125    let ssh_dir = home.join(".ssh");
126    let key_path = ssh_dir.join(AUR_KEY_NAME);
127    if !key_path.exists() {
128        return false;
129    }
130    let config_path = ssh_dir.join("config");
131    let Ok(content) = fs::read_to_string(config_path) else {
132        return false;
133    };
134    find_host_block(&content, AUR_HOST)
135        .is_some_and(|(_, _, block)| block_has_required_directives(&block))
136}
137
138/// What: Run the guided AUR SSH setup flow.
139///
140/// Inputs:
141/// - `overwrite_existing_host`: Whether to overwrite a conflicting existing host block.
142///
143/// Output:
144/// - `AurSshSetupResult` with either completion report or explicit overwrite request.
145///
146/// Details:
147/// - Creates `~/.ssh` if missing.
148/// - Generates `~/.ssh/aur_key` via `ssh-keygen` if not present.
149/// - Writes/updates minimal `Host aur.archlinux.org` block.
150#[must_use]
151pub fn run_aur_ssh_setup(overwrite_existing_host: bool) -> AurSshSetupResult {
152    let mut lines = Vec::new();
153    let Some(home) = home_dir() else {
154        return AurSshSetupResult::Completed(AurSshSetupReport {
155            success: false,
156            lines: vec![setup_failure_line(
157                "home",
158                "could not resolve your home directory (HOME may be unset in this session)",
159            )],
160        });
161    };
162    let ssh_dir = home.join(".ssh");
163    if let Err(err) = fs::create_dir_all(&ssh_dir) {
164        return AurSshSetupResult::Completed(AurSshSetupReport {
165            success: false,
166            lines: vec![setup_failure_line(
167                "directory creation",
168                format!("could not create '{}': {err}", ssh_dir.display()),
169            )],
170        });
171    }
172    lines.push(format!("SSH directory ready: '{}'", ssh_dir.display()));
173    #[cfg(unix)]
174    {
175        use std::os::unix::fs::PermissionsExt;
176        if let Err(err) = fs::set_permissions(&ssh_dir, fs::Permissions::from_mode(0o700)) {
177            lines.push(format!(
178                "Warning: could not set '{}' permissions to 700: {err}",
179                ssh_dir.display()
180            ));
181        }
182    }
183    let known_hosts_path = match ensure_known_hosts_file_exists(&ssh_dir, &mut lines) {
184        Ok(path) => path,
185        Err(err) => {
186            return AurSshSetupResult::Completed(AurSshSetupReport {
187                success: false,
188                lines: vec![setup_failure_line("known_hosts", err)],
189            });
190        }
191    };
192    maybe_seed_known_hosts_with_aur_entry(&known_hosts_path, &mut lines);
193
194    let key_path = ssh_dir.join(AUR_KEY_NAME);
195    if key_path.exists() {
196        lines.push(format!("Key exists: '{}'", key_path.display()));
197    } else {
198        let output = Command::new("ssh-keygen")
199            .args(["-t", "ed25519", "-f"])
200            .arg(&key_path)
201            .args(["-N", ""])
202            .output();
203        match output {
204            Ok(out) if out.status.success() => {
205                lines.push(format!("Created key pair: '{}'", key_path.display()));
206            }
207            Ok(out) => {
208                let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
209                lines.push(format!(
210                    "Failed [keygen]: ssh-keygen exited with code {}: {}",
211                    out.status.code().unwrap_or(-1),
212                    if stderr.is_empty() {
213                        "no stderr output".to_string()
214                    } else {
215                        stderr
216                    }
217                ));
218                return AurSshSetupResult::Completed(AurSshSetupReport {
219                    success: false,
220                    lines,
221                });
222            }
223            Err(err) => {
224                lines.push(setup_failure_line(
225                    "keygen",
226                    format!("could not run ssh-keygen: {err}"),
227                ));
228                return AurSshSetupResult::Completed(AurSshSetupReport {
229                    success: false,
230                    lines,
231                });
232            }
233        }
234    }
235
236    let config_path = ssh_dir.join("config");
237    match write_or_update_aur_host_config(&config_path, overwrite_existing_host, &mut lines) {
238        Ok(Some(existing_block)) => {
239            return AurSshSetupResult::NeedsOverwrite {
240                existing_block,
241                lines,
242            };
243        }
244        Ok(None) => {}
245        Err(err) => {
246            lines.push(setup_failure_line(
247                "config update",
248                format!("could not update '{}': {err}", config_path.display()),
249            ));
250            return AurSshSetupResult::Completed(AurSshSetupReport {
251                success: false,
252                lines,
253            });
254        }
255    }
256
257    let pub_key_path = key_path.with_extension("pub");
258    match fs::read_to_string(&pub_key_path) {
259        Ok(pub_key) => {
260            let trimmed = pub_key.trim();
261            if trimmed.is_empty() {
262                lines.push(
263                    "Warning: public key file is empty. Re-run setup or regenerate key."
264                        .to_string(),
265                );
266            } else {
267                lines.push(format!(
268                    "Public key file: '{}' (copy this into your AUR account).",
269                    pub_key_path.display()
270                ));
271                lines.push(trimmed.to_string());
272            }
273        }
274        Err(err) => {
275            lines.push(format!(
276                "Warning: could not read public key '{}': {err}",
277                pub_key_path.display()
278            ));
279        }
280    }
281    lines.push(format!(
282        "Next step: open {AUR_ACCOUNT_URL} and paste the public key."
283    ));
284    AurSshSetupResult::Completed(AurSshSetupReport {
285        success: true,
286        lines,
287    })
288}
289
290/// What: Ensure the `~/.ssh/known_hosts` file exists and has restrictive permissions.
291///
292/// Inputs:
293/// - `ssh_dir`: Existing `~/.ssh` directory path.
294/// - `lines`: Status lines accumulator for UI diagnostics.
295///
296/// Output:
297/// - `Ok(path)` with `known_hosts` path when ready.
298/// - `Err(reason)` when file creation/opening fails.
299fn ensure_known_hosts_file_exists(
300    ssh_dir: &Path,
301    lines: &mut Vec<String>,
302) -> Result<PathBuf, String> {
303    let known_hosts_path = ssh_dir.join("known_hosts");
304    if known_hosts_path.exists() {
305        lines.push(format!(
306            "known_hosts file ready: '{}'",
307            known_hosts_path.display()
308        ));
309    } else {
310        OpenOptions::new()
311            .create(true)
312            .write(true)
313            .truncate(false)
314            .open(&known_hosts_path)
315            .map_err(|err| format!("could not create '{}': {err}", known_hosts_path.display()))?;
316        lines.push(format!(
317            "Created known_hosts file: '{}'",
318            known_hosts_path.display()
319        ));
320    }
321    #[cfg(unix)]
322    {
323        use std::os::unix::fs::PermissionsExt;
324        if let Err(err) = fs::set_permissions(&known_hosts_path, fs::Permissions::from_mode(0o600))
325        {
326            lines.push(format!(
327                "Warning: could not set '{}' permissions to 600: {err}",
328                known_hosts_path.display()
329            ));
330        }
331    }
332    Ok(known_hosts_path)
333}
334
335/// What: Try to add `aur.archlinux.org` host key to `known_hosts`.
336///
337/// Inputs:
338/// - `known_hosts_path`: Absolute `known_hosts` file path.
339/// - `lines`: Status lines accumulator for UI diagnostics.
340///
341/// Output:
342/// - None (best-effort, non-fatal).
343fn maybe_seed_known_hosts_with_aur_entry(known_hosts_path: &Path, lines: &mut Vec<String>) {
344    let content = fs::read_to_string(known_hosts_path).unwrap_or_default();
345    if content.contains(AUR_HOST) {
346        lines.push("known_hosts already contains aur.archlinux.org entry.".to_string());
347        return;
348    }
349    let output = Command::new("ssh-keyscan").args(["-H", AUR_HOST]).output();
350    match output {
351        Ok(out) if out.status.success() && !out.stdout.is_empty() => {
352            match OpenOptions::new().append(true).open(known_hosts_path) {
353                Ok(mut file) => {
354                    if let Err(err) = file.write_all(&out.stdout) {
355                        lines.push(format!(
356                            "Warning: failed to append AUR host key to '{}': {err}",
357                            known_hosts_path.display()
358                        ));
359                    } else {
360                        lines.push("Added aur.archlinux.org host key to known_hosts.".to_string());
361                    }
362                }
363                Err(err) => {
364                    lines.push(format!(
365                        "Warning: failed to open '{}' for host key append: {err}",
366                        known_hosts_path.display()
367                    ));
368                }
369            }
370        }
371        Ok(out) => {
372            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
373            lines.push(format!(
374                "Warning: could not fetch AUR host key via ssh-keyscan (exit {}): {}",
375                out.status.code().unwrap_or(-1),
376                if stderr.is_empty() {
377                    "no stderr output".to_string()
378                } else {
379                    stderr
380                }
381            ));
382        }
383        Err(err) => {
384            lines.push(format!(
385                "Warning: ssh-keyscan unavailable for known_hosts seeding: {err}"
386            ));
387        }
388    }
389}
390
391/// What: Validate AUR SSH connectivity after the user applies the public key.
392///
393/// Inputs:
394/// - `ssh_command`: SSH binary path or name used for validation command execution.
395///
396/// Output:
397/// - `AurSshSetupReport` with success flag and validation-specific status lines.
398#[must_use]
399pub fn validate_aur_ssh_setup_connection(ssh_command: &str) -> AurSshSetupReport {
400    let Some(home) = home_dir() else {
401        return AurSshSetupReport {
402            success: false,
403            lines: vec![setup_failure_line(
404                "home",
405                "could not resolve your home directory (HOME may be unset in this session)",
406            )],
407        };
408    };
409    let key_path = home.join(".ssh").join(AUR_KEY_NAME);
410    let validation = Command::new(ssh_command)
411        .args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"])
412        .arg("aur@aur.archlinux.org")
413        .arg("help")
414        .output();
415    match validation {
416        Ok(out) if out.status.success() => AurSshSetupReport {
417            success: true,
418            lines: vec!["Validation OK: 'ssh aur@aur.archlinux.org help' succeeded.".to_string()],
419        },
420        Ok(out) => {
421            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
422            let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
423            let detail = if stderr.is_empty() { stdout } else { stderr };
424            let mut lines = Vec::new();
425            lines.push(format!(
426                "Failed [connection check]: ssh validation exited with code {}: {}",
427                out.status.code().unwrap_or(-1),
428                if detail.is_empty() {
429                    "no output".to_string()
430                } else {
431                    detail
432                }
433            ));
434            lines.push(format!(
435                "Next step: upload public key '{}' to {}",
436                key_path.with_extension("pub").display(),
437                AUR_ACCOUNT_URL
438            ));
439            AurSshSetupReport {
440                success: false,
441                lines,
442            }
443        }
444        Err(err) => AurSshSetupReport {
445            success: false,
446            lines: vec![setup_failure_line(
447                "connection check",
448                format!("could not run ssh validation command '{ssh_command}': {err}"),
449            )],
450        },
451    }
452}
453
454/// What: Build a standardized setup failure line with stage context.
455fn setup_failure_line(stage: &str, detail: impl AsRef<str>) -> String {
456    format!("Failed [{stage}]: {}", detail.as_ref())
457}
458
459/// What: Spawn a background SSH validation check for AUR endpoint readiness.
460///
461/// Inputs:
462/// - `ssh_command`: SSH binary path or name used for the endpoint check.
463///
464/// Output:
465/// - Shared handle containing `Some(true/false)` when finished, or `None` while running.
466///
467/// Details:
468/// - Runs `{ssh_command} -o BatchMode=yes -o ConnectTimeout=8 aur@aur.archlinux.org help`
469///   on a worker thread.
470#[must_use]
471pub fn spawn_aur_ssh_help_check(ssh_command: String) -> Arc<Mutex<Option<bool>>> {
472    let result = Arc::new(Mutex::new(None));
473    let result_clone = Arc::clone(&result);
474    std::thread::spawn(move || {
475        let ok = Command::new(&ssh_command)
476            .args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=8"])
477            .arg("aur@aur.archlinux.org")
478            .arg("help")
479            .output()
480            .is_ok_and(|out| out.status.success());
481        if let Ok(mut slot) = result_clone.lock() {
482            *slot = Some(ok);
483        }
484    });
485    result
486}
487
488/// What: Resolve current user's home directory.
489fn home_dir() -> Option<PathBuf> {
490    std::env::var("HOME")
491        .ok()
492        .filter(|v| !v.trim().is_empty())
493        .map(PathBuf::from)
494        .or_else(resolve_home_dir_unix_passwd)
495}
496
497/// What: Resolve home dir via passwd database on Unix.
498///
499/// Inputs: None.
500///
501/// Output:
502/// - `Some(path)` when current uid has a valid passwd home entry.
503///
504/// Details:
505/// - Acts as fallback when `HOME` is unset/empty in the app process environment.
506#[cfg(unix)]
507fn resolve_home_dir_unix_passwd() -> Option<PathBuf> {
508    use nix::unistd::{Uid, User};
509
510    let uid = Uid::current();
511    User::from_uid(uid)
512        .ok()
513        .flatten()
514        .and_then(|user| (!user.dir.as_os_str().is_empty()).then_some(user.dir))
515}
516
517/// What: Non-Unix fallback for home-dir resolution.
518///
519/// Inputs: None.
520///
521/// Output:
522/// - Always `None` on non-Unix targets.
523///
524/// Details:
525/// - Placeholder so `home_dir()` can call a single cross-platform fallback symbol.
526#[cfg(not(unix))]
527fn resolve_home_dir_unix_passwd() -> Option<PathBuf> {
528    None
529}
530
531/// What: Build the target host block text for AUR SSH voting.
532fn desired_aur_host_block() -> String {
533    "Host aur.archlinux.org\n  User aur\n  IdentityFile ~/.ssh/aur_key\n  IdentitiesOnly yes\n"
534        .to_string()
535}
536
537/// What: Find one host block range by host token.
538///
539/// Output:
540/// - `(start_byte, end_byte, block_text)` when found.
541fn find_host_block(content: &str, host: &str) -> Option<(usize, usize, String)> {
542    let mut entries: Vec<(usize, &str)> = Vec::new();
543    let mut start = 0usize;
544    for line in content.lines() {
545        entries.push((start, line));
546        start = start.saturating_add(line.len()).saturating_add(1);
547    }
548    let mut block_start: Option<usize> = None;
549    let mut end = content.len();
550    for (line_start, line) in entries {
551        let trimmed = line.trim();
552        if !trimmed.starts_with("Host ") {
553            continue;
554        }
555        if block_start.is_none() {
556            let hosts = trimmed.trim_start_matches("Host ").split_whitespace();
557            if hosts.into_iter().any(|entry| entry == host) {
558                block_start = Some(line_start);
559            }
560            continue;
561        }
562        end = line_start;
563        break;
564    }
565    let start = block_start?;
566    Some((start, end, content[start..end].trim_end().to_string()))
567}
568
569/// What: Determine whether a host block contains required directives.
570fn block_has_required_directives(block: &str) -> bool {
571    let mut user_ok = false;
572    let mut id_ok = false;
573    let mut only_ok = false;
574    for line in block.lines() {
575        let trimmed = line.trim();
576        if trimmed.eq_ignore_ascii_case("User aur") {
577            user_ok = true;
578        } else if trimmed.eq_ignore_ascii_case("IdentityFile ~/.ssh/aur_key") {
579            id_ok = true;
580        } else if trimmed.eq_ignore_ascii_case("IdentitiesOnly yes") {
581            only_ok = true;
582        }
583    }
584    user_ok && id_ok && only_ok
585}
586
587/// What: Update `~/.ssh/config` with desired host block.
588///
589/// Output:
590/// - `Ok(Some(existing_block))` when overwrite confirmation is required.
591/// - `Ok(None)` when write/update succeeded or file already compliant.
592fn write_or_update_aur_host_config(
593    config_path: &Path,
594    overwrite_existing_host: bool,
595    lines: &mut Vec<String>,
596) -> Result<Option<String>, String> {
597    let desired = desired_aur_host_block();
598    let mut content = fs::read_to_string(config_path).unwrap_or_default();
599    if let Some((start, end, existing)) = find_host_block(&content, AUR_HOST) {
600        if block_has_required_directives(&existing) {
601            lines.push(format!(
602                "SSH config already contains required '{AUR_HOST}'."
603            ));
604            return Ok(None);
605        }
606        if !overwrite_existing_host {
607            lines.push(format!(
608                "Existing '{AUR_HOST}' block detected. Confirmation required to overwrite."
609            ));
610            return Ok(Some(existing));
611        }
612        content.replace_range(start..end, &desired);
613        lines.push(format!("Overwrote existing '{AUR_HOST}' host block."));
614    } else {
615        if !content.is_empty() && !content.ends_with('\n') {
616            content.push('\n');
617        }
618        if !content.is_empty() {
619            content.push('\n');
620        }
621        content.push_str(&desired);
622        lines.push(format!("Added new '{AUR_HOST}' host block."));
623    }
624    fs::write(config_path, content).map_err(|e| e.to_string())?;
625    #[cfg(unix)]
626    {
627        use std::os::unix::fs::PermissionsExt;
628        if let Err(err) = fs::set_permissions(config_path, fs::Permissions::from_mode(0o600)) {
629            lines.push(format!(
630                "Warning: could not set '{}' permissions to 600: {err}",
631                config_path.display()
632            ));
633        }
634    }
635    Ok(None)
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641    use std::time::{SystemTime, UNIX_EPOCH};
642
643    fn temp_config_path(name: &str) -> PathBuf {
644        let stamp = SystemTime::now()
645            .duration_since(UNIX_EPOCH)
646            .map_or(0, |d| d.as_nanos());
647        std::env::temp_dir().join(format!(
648            "pacsea_{name}_{}_{}.conf",
649            std::process::id(),
650            stamp
651        ))
652    }
653
654    #[test]
655    fn block_directives_detected() {
656        let block = "Host aur.archlinux.org\n  User aur\n  IdentityFile ~/.ssh/aur_key\n  IdentitiesOnly yes\n";
657        assert!(block_has_required_directives(block));
658    }
659
660    #[test]
661    fn block_directives_missing_detected() {
662        let block = "Host aur.archlinux.org\n  User aur\n  IdentityFile ~/.ssh/id_ed25519\n";
663        assert!(!block_has_required_directives(block));
664    }
665
666    #[test]
667    fn find_host_block_returns_range() {
668        let content = "Host github.com\n  User git\n\nHost aur.archlinux.org\n  User aur\n";
669        let found = find_host_block(content, "aur.archlinux.org")
670            .expect("expected aur host block to be found");
671        assert!(found.2.contains("Host aur.archlinux.org"));
672    }
673
674    #[test]
675    fn write_or_update_requests_overwrite_for_conflicting_block() {
676        let path = temp_config_path("ssh_setup_conflict");
677        let original = "Host aur.archlinux.org\n  User aur\n  IdentityFile ~/.ssh/id_ed25519\n";
678        fs::write(&path, original).expect("should write temp config");
679        let mut lines = Vec::new();
680        let result =
681            write_or_update_aur_host_config(&path, false, &mut lines).expect("should not error");
682        assert!(
683            result.is_some(),
684            "conflicting block should request overwrite"
685        );
686        let _ = fs::remove_file(path);
687    }
688
689    #[test]
690    fn write_or_update_writes_expected_block_when_missing() {
691        let path = temp_config_path("ssh_setup_missing");
692        let _ = fs::remove_file(&path);
693        let mut lines = Vec::new();
694        let result =
695            write_or_update_aur_host_config(&path, false, &mut lines).expect("should not error");
696        assert!(result.is_none(), "missing block should be written directly");
697        let body = fs::read_to_string(&path).expect("config should be created");
698        assert!(body.contains("Host aur.archlinux.org"));
699        assert!(body.contains("IdentityFile ~/.ssh/aur_key"));
700        let _ = fs::remove_file(path);
701    }
702
703    #[test]
704    fn openssh_check_honors_test_override() {
705        unsafe {
706            std::env::set_var("PACSEA_TEST_OPENSSH_INSTALLED", "1");
707        }
708        assert!(is_openssh_installed());
709        unsafe {
710            std::env::set_var("PACSEA_TEST_OPENSSH_INSTALLED", "0");
711        }
712        assert!(!is_openssh_installed());
713        unsafe {
714            std::env::remove_var("PACSEA_TEST_OPENSSH_INSTALLED");
715        }
716    }
717
718    #[test]
719    fn setup_failure_line_includes_stage_and_detail() {
720        let line = setup_failure_line("connection check", "network timeout");
721        assert_eq!(
722            line,
723            "Failed [connection check]: network timeout".to_string()
724        );
725    }
726
727    #[test]
728    fn ssh_public_key_line_from_status_finds_first_key_line() {
729        let lines = vec![
730            "Key exists: '/home/u/.ssh/aur_key'".to_string(),
731            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIABCD user@host".to_string(),
732            "Next step: open https://example.test/login".to_string(),
733        ];
734        assert_eq!(
735            ssh_public_key_line_from_status_lines(&lines).as_deref(),
736            Some("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIABCD user@host")
737        );
738    }
739
740    #[test]
741    fn ensure_known_hosts_file_exists_creates_missing_file() {
742        let stamp = SystemTime::now()
743            .duration_since(UNIX_EPOCH)
744            .map_or(0, |d| d.as_nanos());
745        let ssh_dir = std::env::temp_dir().join(format!(
746            "pacsea_ssh_known_hosts_{}_{}",
747            std::process::id(),
748            stamp
749        ));
750        fs::create_dir_all(&ssh_dir).expect("should create temp ssh dir");
751        let mut lines = Vec::new();
752        let path = ensure_known_hosts_file_exists(&ssh_dir, &mut lines)
753            .expect("should create known_hosts");
754        assert!(path.exists(), "known_hosts file should exist");
755        assert!(
756            lines
757                .iter()
758                .any(|line| line.contains("Created known_hosts file")),
759            "status lines should mention known_hosts creation"
760        );
761        let _ = fs::remove_file(path);
762        let _ = fs::remove_dir(ssh_dir);
763    }
764}