use std::fs;
use std::io::{BufRead, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
#[derive(Debug, Clone)]
pub struct PubkeyCandidate {
pub path: PathBuf,
pub line: String,
pub fingerprint: String,
}
impl PubkeyCandidate {
pub fn from_path(path: &Path) -> Result<Self> {
let content = fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let line = content
.lines()
.map(str::trim)
.find(|l| !l.is_empty() && !l.starts_with('#'))
.ok_or_else(|| anyhow::anyhow!("no SSH public key line in {}", path.display()))?
.to_string();
let fingerprint = sshenv_vault::recipient::fingerprint_from_line(&line)
.with_context(|| format!("failed to parse SSH public key in {}", path.display()))?;
Ok(Self {
path: path.to_path_buf(),
line,
fingerprint,
})
}
}
pub fn select_pubkey_interactive<R: BufRead, W: Write>(
candidates: &[PubkeyCandidate],
stdin: &mut R,
stderr: &mut W,
tty: bool,
) -> Result<PubkeyCandidate> {
if !tty {
bail!(
"not running interactively; pass --recipient-key explicitly.\n\
Example: sshenv ... --recipient-key ~/.ssh/id_ed25519.pub"
);
}
if candidates.is_empty() {
bail!(
"no SSH public keys found in ~/.ssh/ or ~/.ssh/config.\n\
Generate one with `ssh-keygen -t ed25519`, or pass \
--recipient-key <path-or-line> explicitly."
);
}
if candidates.len() == 1 {
let only = &candidates[0];
writeln!(
stderr,
"Found SSH public key: {} ({})",
only.path.display(),
only.fingerprint
)?;
write!(stderr, "Use this key? [Y/n] ")?;
stderr.flush()?;
let mut buf = String::new();
stdin
.read_line(&mut buf)
.context("failed to read selection from stdin")?;
let answer = buf.trim();
if answer.eq_ignore_ascii_case("n") || answer.eq_ignore_ascii_case("no") {
bail!("cancelled; no recipient selected");
}
return Ok(only.clone());
}
writeln!(stderr, "Multiple SSH public keys found. Pick one:")?;
for (i, c) in candidates.iter().enumerate() {
writeln!(
stderr,
" {}) {} ({})",
i + 1,
c.path.display(),
c.fingerprint
)?;
}
write!(stderr, "Enter a number (or 'q' to cancel): ")?;
stderr.flush()?;
let mut buf = String::new();
stdin
.read_line(&mut buf)
.context("failed to read selection from stdin")?;
let answer = buf.trim();
if answer.eq_ignore_ascii_case("q") {
bail!("cancelled; no recipient selected");
}
let Ok(n) = answer.parse::<usize>() else {
bail!("invalid selection: {answer:?}");
};
if n == 0 || n > candidates.len() {
bail!(
"selection out of range: {n} (expected 1..={})",
candidates.len()
);
}
Ok(candidates[n - 1].clone())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
fn cand(path: &str, fp: &str) -> PubkeyCandidate {
PubkeyCandidate {
path: PathBuf::from(path),
line: format!("ssh-ed25519 AAAA... {path}"),
fingerprint: fp.to_string(),
}
}
#[test]
fn non_tty_always_errors_with_pointer_to_flag() {
let mut stdin = Cursor::new(b"1\n");
let mut stderr: Vec<u8> = Vec::new();
let candidates = vec![cand("/tmp/k.pub", "SHA256:abc")];
let err =
select_pubkey_interactive(&candidates, &mut stdin, &mut stderr, false).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("--recipient-key"), "got: {msg}");
assert!(
stderr.is_empty(),
"nothing should have been written to stderr"
);
}
#[test]
fn empty_candidates_errors_even_in_tty() {
let mut stdin = Cursor::new(b"");
let mut stderr: Vec<u8> = Vec::new();
let err = select_pubkey_interactive(&[], &mut stdin, &mut stderr, true).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("no SSH public keys found"), "got: {msg}");
assert!(msg.contains("ssh-keygen"), "got: {msg}");
}
#[test]
fn single_candidate_accepted_on_empty_input() {
let mut stdin = Cursor::new(b"\n");
let mut stderr: Vec<u8> = Vec::new();
let candidates = vec![cand("/tmp/k.pub", "SHA256:abc")];
let picked = select_pubkey_interactive(&candidates, &mut stdin, &mut stderr, true)
.expect("should accept");
assert_eq!(picked.fingerprint, "SHA256:abc");
}
#[test]
fn single_candidate_accepted_on_y() {
let mut stdin = Cursor::new(b"y\n");
let mut stderr: Vec<u8> = Vec::new();
let candidates = vec![cand("/tmp/k.pub", "SHA256:abc")];
let picked = select_pubkey_interactive(&candidates, &mut stdin, &mut stderr, true).unwrap();
assert_eq!(picked.fingerprint, "SHA256:abc");
}
#[test]
fn single_candidate_rejected_on_n() {
let mut stdin = Cursor::new(b"n\n");
let mut stderr: Vec<u8> = Vec::new();
let candidates = vec![cand("/tmp/k.pub", "SHA256:abc")];
let err =
select_pubkey_interactive(&candidates, &mut stdin, &mut stderr, true).unwrap_err();
assert!(err.to_string().contains("cancelled"));
}
#[test]
fn single_candidate_rejected_on_no_word() {
let mut stdin = Cursor::new(b"no\n");
let mut stderr: Vec<u8> = Vec::new();
let candidates = vec![cand("/tmp/k.pub", "SHA256:abc")];
let err =
select_pubkey_interactive(&candidates, &mut stdin, &mut stderr, true).unwrap_err();
assert!(err.to_string().contains("cancelled"));
}
#[test]
fn multiple_candidates_pick_by_number() {
let mut stdin = Cursor::new(b"2\n");
let mut stderr: Vec<u8> = Vec::new();
let candidates = vec![
cand("/tmp/a.pub", "SHA256:aaa"),
cand("/tmp/b.pub", "SHA256:bbb"),
cand("/tmp/c.pub", "SHA256:ccc"),
];
let picked = select_pubkey_interactive(&candidates, &mut stdin, &mut stderr, true).unwrap();
assert_eq!(picked.fingerprint, "SHA256:bbb");
let rendered = String::from_utf8_lossy(&stderr);
assert!(rendered.contains("1)"));
assert!(rendered.contains("2)"));
assert!(rendered.contains("3)"));
}
#[test]
fn multiple_candidates_cancelled_on_q() {
let mut stdin = Cursor::new(b"q\n");
let mut stderr: Vec<u8> = Vec::new();
let candidates = vec![
cand("/tmp/a.pub", "SHA256:aaa"),
cand("/tmp/b.pub", "SHA256:bbb"),
];
let err =
select_pubkey_interactive(&candidates, &mut stdin, &mut stderr, true).unwrap_err();
assert!(err.to_string().contains("cancelled"));
}
#[test]
fn multiple_candidates_out_of_range_errors() {
let mut stdin = Cursor::new(b"5\n");
let mut stderr: Vec<u8> = Vec::new();
let candidates = vec![
cand("/tmp/a.pub", "SHA256:aaa"),
cand("/tmp/b.pub", "SHA256:bbb"),
];
let err =
select_pubkey_interactive(&candidates, &mut stdin, &mut stderr, true).unwrap_err();
assert!(err.to_string().contains("out of range"));
}
#[test]
fn multiple_candidates_zero_errors() {
let mut stdin = Cursor::new(b"0\n");
let mut stderr: Vec<u8> = Vec::new();
let candidates = vec![
cand("/tmp/a.pub", "SHA256:aaa"),
cand("/tmp/b.pub", "SHA256:bbb"),
];
let err =
select_pubkey_interactive(&candidates, &mut stdin, &mut stderr, true).unwrap_err();
assert!(err.to_string().contains("out of range"));
}
#[test]
fn multiple_candidates_garbage_errors() {
let mut stdin = Cursor::new(b"apple\n");
let mut stderr: Vec<u8> = Vec::new();
let candidates = vec![
cand("/tmp/a.pub", "SHA256:aaa"),
cand("/tmp/b.pub", "SHA256:bbb"),
];
let err =
select_pubkey_interactive(&candidates, &mut stdin, &mut stderr, true).unwrap_err();
assert!(err.to_string().contains("invalid selection"));
}
#[test]
fn from_path_reads_and_fingerprints() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("k.pub");
fs::write(
&p,
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIF1r4mXp9V1d6JEcv5m5d8ONnuQVpMb1i+B7ifqWeu6w braden@test\n",
)
.unwrap();
let c = PubkeyCandidate::from_path(&p).unwrap();
assert!(c.fingerprint.starts_with("SHA256:"));
assert!(c.line.starts_with("ssh-ed25519 "));
}
#[test]
fn from_path_skips_comments_and_blanks() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("k.pub");
fs::write(
&p,
"\n# a comment\n\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIF1r4mXp9V1d6JEcv5m5d8ONnuQVpMb1i+B7ifqWeu6w\n",
)
.unwrap();
let c = PubkeyCandidate::from_path(&p).unwrap();
assert!(c.line.starts_with("ssh-ed25519 "));
}
#[test]
fn from_path_errors_if_no_key_line() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("empty.pub");
fs::write(&p, "\n# just a comment\n\n").unwrap();
assert!(PubkeyCandidate::from_path(&p).is_err());
}
}