Skip to main content

git_sshripped_ssh_identity/
lib.rs

1#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
2#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
3#![allow(clippy::multiple_crate_versions)]
4
5use std::collections::HashSet;
6use std::io::IsTerminal;
7use std::path::PathBuf;
8use std::process::{Command, Stdio};
9use std::time::Duration;
10
11use age::Decryptor;
12use age::Identity;
13use age::secrecy::SecretString;
14use age::ssh::Identity as SshIdentity;
15use anyhow::{Context, Result};
16use git_sshripped_ssh_identity_models::{IdentityDescriptor, IdentitySource};
17use wait_timeout::ChildExt;
18
19#[derive(Clone, Copy)]
20struct TerminalCallbacks;
21
22impl age::Callbacks for TerminalCallbacks {
23    fn display_message(&self, message: &str) {
24        eprintln!("{message}");
25    }
26
27    fn confirm(&self, _message: &str, _yes_string: &str, _no_string: Option<&str>) -> Option<bool> {
28        None
29    }
30
31    fn request_public_string(&self, _description: &str) -> Option<String> {
32        None
33    }
34
35    fn request_passphrase(&self, description: &str) -> Option<SecretString> {
36        if let Ok(passphrase) = std::env::var("GSC_SSH_KEY_PASSPHRASE")
37            && !passphrase.is_empty()
38        {
39            return Some(SecretString::from(passphrase));
40        }
41
42        rpassword::prompt_password(format!("{description}: "))
43            .ok()
44            .map(SecretString::from)
45    }
46}
47
48/// Maximum number of passphrase attempts for encrypted SSH keys.
49const MAX_PASSPHRASE_ATTEMPTS: u32 = 3;
50
51/// Decrypt a passphrase-protected SSH key.
52///
53/// Resolution order for the passphrase:
54/// 1. macOS Keychain (`security find-generic-password`)
55/// 2. `GSC_SSH_KEY_PASSPHRASE` environment variable
56/// 3. Interactive terminal prompt via `rpassword` (up to
57///    [`MAX_PASSPHRASE_ATTEMPTS`] attempts)
58///
59/// # Errors
60///
61/// Returns an error if the passphrase is wrong after all attempts or if
62/// the terminal prompt fails.
63fn decrypt_encrypted_key(
64    enc: &age::ssh::EncryptedKey,
65    path: &std::path::Path,
66) -> Result<SshIdentity> {
67    // 1. Try macOS Keychain (silent, no prompt).
68    if let Some(passphrase) = try_macos_keychain_passphrase(path)
69        && let Ok(decrypted) = enc.decrypt(passphrase)
70    {
71        return Ok(SshIdentity::from(decrypted));
72    }
73
74    // 2. Try env var / interactive prompt with retries.
75    for attempt in 1..=MAX_PASSPHRASE_ATTEMPTS {
76        let passphrase = if let Ok(p) = std::env::var("GSC_SSH_KEY_PASSPHRASE")
77            && !p.is_empty()
78        {
79            SecretString::from(p)
80        } else {
81            let prompt = format!("Enter passphrase for {}", path.display());
82            let p = rpassword::prompt_password(format!("{prompt}: "))
83                .context("failed to read passphrase from terminal")?;
84            SecretString::from(p)
85        };
86
87        match enc.decrypt(passphrase) {
88            Ok(decrypted) => return Ok(SshIdentity::from(decrypted)),
89            Err(_) if attempt < MAX_PASSPHRASE_ATTEMPTS => {
90                eprintln!("wrong passphrase, try again ({attempt}/{MAX_PASSPHRASE_ATTEMPTS})");
91            }
92            Err(_) => {
93                anyhow::bail!(
94                    "failed to decrypt {} after {MAX_PASSPHRASE_ATTEMPTS} attempts",
95                    path.display()
96                );
97            }
98        }
99    }
100    unreachable!()
101}
102
103/// Try to retrieve the SSH key passphrase from the macOS login Keychain.
104///
105/// Apple's `ssh-add --apple-use-keychain` stores passphrases with the
106/// service name `"SSH: /path/to/key"`.  This function queries that entry
107/// via the `security` CLI tool and returns the passphrase if found.
108///
109/// Returns `None` on non-macOS platforms, when the Keychain entry does not
110/// exist, or on any error.
111fn try_macos_keychain_passphrase(key_path: &std::path::Path) -> Option<SecretString> {
112    if !cfg!(target_os = "macos") {
113        return None;
114    }
115
116    let user = std::env::var("USER").ok()?;
117    let service = format!("SSH: {}", key_path.display());
118
119    let output = Command::new("security")
120        .args(["find-generic-password", "-a", &user, "-s", &service, "-w"])
121        .stdin(Stdio::null())
122        .stdout(Stdio::piped())
123        .stderr(Stdio::null())
124        .output()
125        .ok()?;
126
127    if !output.status.success() {
128        return None;
129    }
130
131    let passphrase = String::from_utf8(output.stdout).ok()?;
132    let trimmed = passphrase.trim_end_matches('\n');
133    if trimmed.is_empty() {
134        return None;
135    }
136
137    Some(SecretString::from(trimmed.to_string()))
138}
139
140#[must_use]
141fn ssh_dir() -> Option<PathBuf> {
142    dirs::home_dir().map(|h| h.join(".ssh"))
143}
144
145/// Scan `~/.ssh/` for all files ending in `.pub` that have a corresponding
146/// private key file (same path without `.pub`).  Returns `(private, public)` pairs.
147#[must_use]
148pub fn discover_ssh_key_files() -> Vec<(PathBuf, PathBuf)> {
149    let Some(ssh_dir) = ssh_dir() else {
150        return Vec::new();
151    };
152
153    let Ok(entries) = std::fs::read_dir(&ssh_dir) else {
154        return Vec::new();
155    };
156
157    let mut pairs = Vec::new();
158    for entry in entries.flatten() {
159        let pub_path = entry.path();
160        if !pub_path.is_file() {
161            continue;
162        }
163        let Some(name) = pub_path.file_name().and_then(|n| n.to_str()) else {
164            continue;
165        };
166        if !std::path::Path::new(name)
167            .extension()
168            .is_some_and(|ext| ext.eq_ignore_ascii_case("pub"))
169        {
170            continue;
171        }
172        let private_path = pub_path.with_extension("");
173        if private_path.is_file() {
174            pairs.push((private_path, pub_path));
175        }
176    }
177    pairs
178}
179
180/// Parse `~/.ssh/config` and extract all `IdentityFile` directive values.
181/// Expands leading `~` and `~/` to the user's home directory.
182#[must_use]
183pub fn identity_files_from_ssh_config() -> Vec<PathBuf> {
184    let Some(ssh_dir) = ssh_dir() else {
185        return Vec::new();
186    };
187
188    let config_path = ssh_dir.join("config");
189    let Ok(text) = std::fs::read_to_string(&config_path) else {
190        return Vec::new();
191    };
192
193    parse_identity_files_from_config(&text, dirs::home_dir().as_deref())
194}
195
196fn parse_identity_files_from_config(text: &str, home: Option<&std::path::Path>) -> Vec<PathBuf> {
197    text.lines()
198        .map(str::trim)
199        .filter(|line| {
200            !line.is_empty()
201                && !line.starts_with('#')
202                && line.len() > 12
203                && line[..12].eq_ignore_ascii_case("identityfile")
204        })
205        .filter_map(|line| {
206            let value =
207                line[12..].trim_start_matches(|c: char| c == '=' || c.is_ascii_whitespace());
208            if value.is_empty() {
209                return None;
210            }
211            let expanded = if value == "~" {
212                home?.to_path_buf()
213            } else if let Some(rest) = value.strip_prefix("~/") {
214                home?.join(rest)
215            } else {
216                PathBuf::from(value)
217            };
218            Some(expanded)
219        })
220        .collect()
221}
222
223#[must_use]
224pub fn default_public_key_candidates() -> Vec<PathBuf> {
225    let mut candidates = well_known_public_key_paths();
226
227    // Public keys for IdentityFile entries from ~/.ssh/config
228    for private in identity_files_from_ssh_config() {
229        let public = private.with_extension("pub");
230        if !candidates.contains(&public) {
231            candidates.push(public);
232        }
233    }
234
235    // All discovered .pub files from ~/.ssh/
236    for (_, pub_path) in discover_ssh_key_files() {
237        if !candidates.contains(&pub_path) {
238            candidates.push(pub_path);
239        }
240    }
241
242    candidates
243}
244
245/// Returns only the well-known standard public key paths.
246///
247/// Returns `id_ed25519.pub` and `id_rsa.pub` from `~/.ssh/`.  Use this when
248/// auto-adding recipients during `init` -- we don't want to silently add
249/// every key in `~/.ssh/` as a recipient.
250#[must_use]
251pub fn well_known_public_key_paths() -> Vec<PathBuf> {
252    let mut candidates = Vec::new();
253    if let Some(ssh_dir) = ssh_dir() {
254        candidates.push(ssh_dir.join("id_ed25519.pub"));
255        candidates.push(ssh_dir.join("id_rsa.pub"));
256    }
257    candidates
258}
259
260#[must_use]
261pub fn default_private_key_candidates() -> Vec<PathBuf> {
262    let mut candidates = Vec::new();
263
264    // Hardcoded standard locations first
265    if let Some(ssh_dir) = ssh_dir() {
266        candidates.push(ssh_dir.join("id_ed25519"));
267        candidates.push(ssh_dir.join("id_rsa"));
268    }
269
270    // IdentityFile entries from ~/.ssh/config
271    for path in identity_files_from_ssh_config() {
272        if !candidates.contains(&path) {
273            candidates.push(path);
274        }
275    }
276
277    // All discovered private key files from ~/.ssh/
278    for (private, _) in discover_ssh_key_files() {
279        if !candidates.contains(&private) {
280            candidates.push(private);
281        }
282    }
283
284    candidates
285}
286
287/// Query the SSH agent for loaded public keys.
288///
289/// # Errors
290///
291/// Returns an error if `ssh-add -L` fails to execute or produces non-UTF-8
292/// output.
293/// List public key strings for all identities currently loaded in the SSH
294/// agent, in the same `key-type base64-data [comment]` format as
295/// `ssh-add -L`.
296///
297/// Returns an empty vec when `SSH_AUTH_SOCK` is not set, the agent is
298/// unreachable, or the agent has no keys.
299///
300/// # Errors
301///
302/// Returns an error only on unexpected failures *after* a successful
303/// connection.
304pub fn agent_public_keys() -> Result<Vec<String>> {
305    let Some(sock) = std::env::var_os("SSH_AUTH_SOCK") else {
306        return Ok(Vec::new());
307    };
308    let sock_path = std::path::Path::new(&sock);
309    let Ok(mut client) = ssh_agent_client_rs::Client::connect(sock_path) else {
310        return Ok(Vec::new());
311    };
312
313    let identities = client
314        .list_all_identities()
315        .context("failed to list SSH agent identities")?;
316
317    let mut keys = Vec::new();
318    for identity in identities {
319        let pubkey: &ssh_key::PublicKey = match &identity {
320            ssh_agent_client_rs::Identity::PublicKey(boxed_cow) => boxed_cow.as_ref(),
321            ssh_agent_client_rs::Identity::Certificate(_) => continue,
322        };
323        keys.push(pubkey.to_openssh().unwrap_or_default());
324    }
325    Ok(keys)
326}
327
328/// Find local private key files whose public keys are loaded in the SSH agent.
329///
330/// # Errors
331///
332/// Returns an error if the agent cannot be queried or a public key file
333/// cannot be read.
334pub fn private_keys_matching_agent() -> Result<Vec<PathBuf>> {
335    let agent_keys = agent_public_keys()?;
336    if agent_keys.is_empty() {
337        return Ok(Vec::new());
338    }
339
340    let mut matches = Vec::new();
341    for public_candidate in default_public_key_candidates() {
342        if !public_candidate.exists() {
343            continue;
344        }
345
346        let public_line = std::fs::read_to_string(&public_candidate).with_context(|| {
347            format!(
348                "failed reading public key candidate {}",
349                public_candidate.display()
350            )
351        })?;
352
353        let pub_trimmed = public_line.trim();
354        // Compare only the key-type + key-data portion (first two tokens),
355        // ignoring trailing comment which may differ between agent and file.
356        let pub_key_data: String = pub_trimmed
357            .split_whitespace()
358            .take(2)
359            .collect::<Vec<_>>()
360            .join(" ");
361
362        let agent_match = agent_keys.iter().any(|agent_line| {
363            let agent_data: String = agent_line
364                .split_whitespace()
365                .take(2)
366                .collect::<Vec<_>>()
367                .join(" ");
368            agent_data == pub_key_data
369        });
370
371        if !agent_match {
372            continue;
373        }
374
375        if let Some(stem) = public_candidate.file_name().and_then(|s| s.to_str())
376            && let Some(private_name) = stem.strip_suffix(".pub")
377        {
378            let private_path = public_candidate
379                .parent()
380                .map_or_else(|| PathBuf::from(private_name), |p| p.join(private_name));
381            if private_path.exists() {
382                matches.push(private_path);
383            }
384        }
385    }
386
387    Ok(matches)
388}
389
390fn parse_helper_key_output(output: &[u8]) -> Result<Option<Vec<u8>>> {
391    if output.len() == 32 {
392        return Ok(Some(output.to_vec()));
393    }
394
395    let text = String::from_utf8(output.to_vec()).context("agent helper output was not utf8")?;
396    let trimmed = text.trim();
397    if trimmed.is_empty() {
398        return Ok(None);
399    }
400
401    if trimmed.len() == 64 {
402        let decoded = hex::decode(trimmed).context("agent helper output was invalid hex")?;
403        if decoded.len() == 32 {
404            return Ok(Some(decoded));
405        }
406    }
407
408    anyhow::bail!("agent helper output must be 32 raw bytes or 64-char hex-encoded key")
409}
410
411/// Unwrap a repo key using an external agent helper program.
412///
413/// # Errors
414///
415/// Returns an error if the helper cannot be spawned, times out, or produces
416/// invalid output.
417pub fn unwrap_repo_key_with_agent_helper(
418    wrapped_files: &[PathBuf],
419    helper_path: &std::path::Path,
420    timeout_ms: u64,
421) -> Result<Option<(Vec<u8>, IdentityDescriptor)>> {
422    for wrapped in wrapped_files {
423        let mut child = Command::new(helper_path)
424            .arg(wrapped)
425            .stdout(Stdio::piped())
426            .stderr(Stdio::piped())
427            .spawn()
428            .with_context(|| {
429                format!(
430                    "failed running agent helper '{}': {}",
431                    helper_path.display(),
432                    wrapped.display()
433                )
434            })?;
435
436        let timeout = Duration::from_millis(timeout_ms);
437        let status = child
438            .wait_timeout(timeout)
439            .context("failed waiting on agent helper process")?;
440
441        let output = if status.is_some() {
442            child
443                .wait_with_output()
444                .context("failed collecting agent helper output")?
445        } else {
446            let _ = child.kill();
447            let _ = child.wait();
448            anyhow::bail!(
449                "agent helper timed out after {}ms for {}",
450                timeout_ms,
451                wrapped.display()
452            );
453        };
454
455        if !output.status.success() {
456            continue;
457        }
458
459        let Some(key) = parse_helper_key_output(&output.stdout)? else {
460            continue;
461        };
462
463        return Ok(Some((
464            key,
465            IdentityDescriptor {
466                source: IdentitySource::SshAgent,
467                label: format!("{} ({})", helper_path.display(), wrapped.display()),
468            },
469        )));
470    }
471
472    Ok(None)
473}
474
475/// Auto-detect the best available SSH identity.
476///
477/// # Errors
478///
479/// This function is infallible in practice but returns `Result` for
480/// consistency with the rest of the API.
481pub fn detect_identity() -> Result<IdentityDescriptor> {
482    if std::env::var_os("SSH_AUTH_SOCK").is_some() {
483        return Ok(IdentityDescriptor {
484            source: IdentitySource::SshAgent,
485            label: "SSH agent".to_string(),
486        });
487    }
488
489    for candidate in default_public_key_candidates() {
490        if candidate.exists() {
491            return Ok(IdentityDescriptor {
492                source: IdentitySource::IdentityFile,
493                label: candidate.display().to_string(),
494            });
495        }
496    }
497
498    Ok(IdentityDescriptor {
499        source: IdentitySource::IdentityFile,
500        label: "unresolved".to_string(),
501    })
502}
503
504/// Try each identity to decrypt a wrapped repo key file.
505///
506/// # Errors
507///
508/// Returns an error if identity files cannot be read or parsed, or if a
509/// wrapped key file is malformed.
510pub fn unwrap_repo_key_from_wrapped_files<S: ::std::hash::BuildHasher>(
511    wrapped_files: &[PathBuf],
512    identity_files: &[PathBuf],
513    interactive_identities: &HashSet<PathBuf, S>,
514) -> Result<Option<(Vec<u8>, IdentityDescriptor)>> {
515    let mut identities: Vec<(SshIdentity, PathBuf)> = Vec::new();
516    let mut skipped_encrypted: Vec<(age::ssh::EncryptedKey, PathBuf)> = Vec::new();
517
518    for identity_file in identity_files {
519        if !identity_file.exists() {
520            continue;
521        }
522        let content = std::fs::read(identity_file)
523            .with_context(|| format!("failed reading identity file {}", identity_file.display()))?;
524        let filename = Some(identity_file.display().to_string());
525        let identity = SshIdentity::from_buffer(std::io::Cursor::new(&content), filename)
526            .with_context(|| format!("failed parsing identity file {}", identity_file.display()))?;
527        if let SshIdentity::Encrypted(ref enc) = identity {
528            if !interactive_identities.contains(identity_file) {
529                // On macOS, try the Keychain before stashing for later.
530                if let Some(passphrase) = try_macos_keychain_passphrase(identity_file)
531                    && let Ok(decrypted) = enc.decrypt(passphrase)
532                {
533                    identities.push((SshIdentity::from(decrypted), identity_file.clone()));
534                    continue;
535                }
536                // Stash for interactive fallback instead of skipping outright.
537                skipped_encrypted.push((enc.clone(), identity_file.clone()));
538                continue;
539            }
540            // --identity was passed: decrypt directly (prompts if needed).
541            let decrypted = decrypt_encrypted_key(enc, identity_file)?;
542            identities.push((decrypted, identity_file.clone()));
543        } else {
544            identities.push((identity, identity_file.clone()));
545        }
546    }
547
548    // First pass: try all passwordless identities (unencrypted + Keychain-decrypted).
549    if let Some(result) = try_decrypt_wrapped_files(wrapped_files, &identities)? {
550        return Ok(Some(result));
551    }
552
553    // Second pass: if we have encrypted keys that were skipped, offer
554    // interactive passphrase entry (only when stdin is a TTY).
555    if !skipped_encrypted.is_empty() && std::io::stdin().is_terminal() {
556        return try_interactive_encrypted_key(wrapped_files, &skipped_encrypted);
557    }
558
559    Ok(None)
560}
561
562/// Try to decrypt any of the wrapped key files using the given identities.
563fn try_decrypt_wrapped_files(
564    wrapped_files: &[PathBuf],
565    identities: &[(SshIdentity, PathBuf)],
566) -> Result<Option<(Vec<u8>, IdentityDescriptor)>> {
567    for wrapped in wrapped_files {
568        let wrapped_bytes = std::fs::read(wrapped)
569            .with_context(|| format!("failed reading wrapped key {}", wrapped.display()))?;
570
571        for (identity, path) in identities {
572            let decryptor = Decryptor::new_buffered(std::io::Cursor::new(&wrapped_bytes))
573                .with_context(|| format!("invalid wrapped key format {}", wrapped.display()))?;
574            let decrypt_identity = identity.clone().with_callbacks(TerminalCallbacks);
575            let Ok(mut reader) =
576                decryptor.decrypt(std::iter::once(&decrypt_identity as &dyn Identity))
577            else {
578                continue;
579            };
580
581            let mut key = Vec::new();
582            std::io::Read::read_to_end(&mut reader, &mut key).with_context(|| {
583                format!("failed reading decrypted key from {}", wrapped.display())
584            })?;
585            return Ok(Some((
586                key,
587                IdentityDescriptor {
588                    source: IdentitySource::IdentityFile,
589                    label: path.display().to_string(),
590                },
591            )));
592        }
593    }
594    Ok(None)
595}
596
597/// Prompt the user to select an encrypted key and try to decrypt with it.
598fn try_interactive_encrypted_key(
599    wrapped_files: &[PathBuf],
600    skipped: &[(age::ssh::EncryptedKey, PathBuf)],
601) -> Result<Option<(Vec<u8>, IdentityDescriptor)>> {
602    let selected = if skipped.len() == 1 {
603        eprintln!("Trying encrypted key {}...", skipped[0].1.display());
604        Some(0)
605    } else {
606        prompt_key_selection(skipped)
607    };
608
609    let Some(idx) = selected else {
610        return Ok(None);
611    };
612
613    let (enc, path) = &skipped[idx];
614    let decrypted = decrypt_encrypted_key(enc, path)?;
615    let identities = vec![(decrypted, path.clone())];
616    try_decrypt_wrapped_files(wrapped_files, &identities)
617}
618
619/// Display a selection menu for encrypted keys and return the chosen index.
620fn prompt_key_selection(keys: &[(age::ssh::EncryptedKey, PathBuf)]) -> Option<usize> {
621    eprintln!("No passwordless unlock method available.");
622    eprintln!("The following encrypted keys were found:");
623    eprintln!();
624    for (i, (_, path)) in keys.iter().enumerate() {
625        eprintln!("  {}) {}", i + 1, path.display());
626    }
627    eprintln!();
628    eprint!("Enter the number of the key to try (or 'q' to cancel): ");
629
630    let mut input = String::new();
631    std::io::stdin().read_line(&mut input).ok()?;
632    let trimmed = input.trim();
633    if trimmed.eq_ignore_ascii_case("q") {
634        return None;
635    }
636    let num: usize = trimmed.parse().ok()?;
637    if num >= 1 && num <= keys.len() {
638        Some(num - 1)
639    } else {
640        None
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647    use std::path::Path;
648
649    #[test]
650    fn parse_config_extracts_identity_files_with_tilde() {
651        let config = "\
652Host github.com
653    User git
654    IdentityFile ~/.ssh/github
655
656Host *
657    ControlMaster auto
658";
659        let home = Path::new("/home/testuser");
660        let result = parse_identity_files_from_config(config, Some(home));
661        assert_eq!(result, vec![PathBuf::from("/home/testuser/.ssh/github")]);
662    }
663
664    #[test]
665    fn parse_config_extracts_multiple_identity_files() {
666        let config = "\
667Host work
668    IdentityFile ~/.ssh/work_key
669
670Host personal
671    IdentityFile ~/.ssh/personal_key
672
673Host github.com
674    IdentityFile ~/.ssh/github
675";
676        let home = Path::new("/Users/user");
677        let result = parse_identity_files_from_config(config, Some(home));
678        assert_eq!(
679            result,
680            vec![
681                PathBuf::from("/Users/user/.ssh/work_key"),
682                PathBuf::from("/Users/user/.ssh/personal_key"),
683                PathBuf::from("/Users/user/.ssh/github"),
684            ]
685        );
686    }
687
688    #[test]
689    fn parse_config_handles_absolute_paths() {
690        let config = "IdentityFile /opt/keys/deploy_key\n";
691        let home = Path::new("/home/user");
692        let result = parse_identity_files_from_config(config, Some(home));
693        assert_eq!(result, vec![PathBuf::from("/opt/keys/deploy_key")]);
694    }
695
696    #[test]
697    fn parse_config_skips_comments_and_blank_lines() {
698        let config = "\
699# This is a comment
700  # indented comment
701
702Host foo
703    # IdentityFile ~/.ssh/commented_out
704    IdentityFile ~/.ssh/real_key
705";
706        let home = Path::new("/home/user");
707        let result = parse_identity_files_from_config(config, Some(home));
708        assert_eq!(result, vec![PathBuf::from("/home/user/.ssh/real_key")]);
709    }
710
711    #[test]
712    fn parse_config_case_insensitive_directive() {
713        let config =
714            "identityfile ~/.ssh/lower\nIDENTITYFILE ~/.ssh/upper\nIdentityFile ~/.ssh/mixed\n";
715        let home = Path::new("/home/user");
716        let result = parse_identity_files_from_config(config, Some(home));
717        assert_eq!(
718            result,
719            vec![
720                PathBuf::from("/home/user/.ssh/lower"),
721                PathBuf::from("/home/user/.ssh/upper"),
722                PathBuf::from("/home/user/.ssh/mixed"),
723            ]
724        );
725    }
726
727    #[test]
728    fn parse_config_handles_equals_separator() {
729        let config = "IdentityFile=~/.ssh/equals_key\n";
730        let home = Path::new("/home/user");
731        let result = parse_identity_files_from_config(config, Some(home));
732        assert_eq!(result, vec![PathBuf::from("/home/user/.ssh/equals_key")]);
733    }
734
735    #[test]
736    fn parse_config_empty_input() {
737        let result = parse_identity_files_from_config("", Some(Path::new("/home/user")));
738        assert!(result.is_empty());
739    }
740
741    #[test]
742    fn parse_config_no_home_skips_tilde_paths() {
743        let config = "IdentityFile ~/.ssh/key\nIdentityFile /abs/key\n";
744        let result = parse_identity_files_from_config(config, None);
745        assert_eq!(result, vec![PathBuf::from("/abs/key")]);
746    }
747
748    #[test]
749    fn discover_keys_in_temp_dir() {
750        let temp = tempfile::TempDir::new().expect("temp dir should create");
751        let ssh_dir = temp.path();
752
753        // Create a standard key pair
754        std::fs::write(ssh_dir.join("id_ed25519"), "private").unwrap();
755        std::fs::write(ssh_dir.join("id_ed25519.pub"), "ssh-ed25519 AAAA...").unwrap();
756
757        // Create a custom-named key pair
758        std::fs::write(ssh_dir.join("github"), "private").unwrap();
759        std::fs::write(ssh_dir.join("github.pub"), "ssh-ed25519 BBBB...").unwrap();
760
761        // Create a .pub file with no matching private key (should be skipped)
762        std::fs::write(ssh_dir.join("orphan.pub"), "ssh-rsa CCCC...").unwrap();
763
764        // Create a non-.pub file (should be ignored)
765        std::fs::write(ssh_dir.join("known_hosts"), "stuff").unwrap();
766
767        // Create a subdirectory with .pub extension (should be ignored)
768        std::fs::create_dir(ssh_dir.join("agent.pub")).unwrap();
769
770        // Use the same logic as discover_ssh_key_files but against our temp dir
771        let entries = std::fs::read_dir(ssh_dir).unwrap();
772        let mut pairs = Vec::new();
773        for entry in entries.flatten() {
774            let pub_path = entry.path();
775            if !pub_path.is_file() {
776                continue;
777            }
778            let Some(name) = pub_path.file_name().and_then(|n| n.to_str()) else {
779                continue;
780            };
781            if !std::path::Path::new(name)
782                .extension()
783                .is_some_and(|ext| ext.eq_ignore_ascii_case("pub"))
784            {
785                continue;
786            }
787            let private_path = pub_path.with_extension("");
788            if private_path.is_file() {
789                pairs.push((private_path, pub_path));
790            }
791        }
792
793        pairs.sort();
794        assert_eq!(pairs.len(), 2);
795
796        let names: Vec<&str> = pairs
797            .iter()
798            .map(|(p, _)| p.file_name().unwrap().to_str().unwrap())
799            .collect();
800        assert!(names.contains(&"github"));
801        assert!(names.contains(&"id_ed25519"));
802    }
803}