use std::process::Stdio;
use crate::error::{Error, Result};
use crate::util::subprocess::spawn_clean;
pub fn parse_dest(target: &str) -> &str {
target.strip_prefix("ssh://").unwrap_or(target)
}
fn validate_dest(dest: &str) -> Result<()> {
if dest.is_empty() {
return Err(Error::Other("invalid ssh destination".into()));
}
if dest.starts_with('-') {
return Err(Error::Other("invalid ssh destination".into()));
}
let ok = dest
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '@' | ':' | '/' | '-'));
if !ok {
return Err(Error::Other("invalid ssh destination".into()));
}
Ok(())
}
pub fn send(data: &[u8], dest: &str) -> Result<()> {
let dest = parse_dest(dest);
validate_dest(dest)?;
let mut child = spawn_clean("ssh")
.arg("--")
.arg(dest)
.arg("envstash receive")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| Error::Other(format!("failed to spawn ssh: {e}")))?;
{
use std::io::Write;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| Error::Other("ssh stdin not available".into()))?;
stdin
.write_all(data)
.map_err(|e| Error::Other(format!("failed to write to ssh stdin: {e}")))?;
}
let output = child
.wait_with_output()
.map_err(|e| Error::Other(format!("ssh wait failed: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::Other(format!("ssh failed: {stderr}")));
}
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.is_empty() {
eprint!("{stdout}");
}
Ok(())
}
pub fn fetch(source: &str) -> Result<Vec<u8>> {
let dest = parse_dest(source);
validate_dest(dest)?;
let output = spawn_clean("ssh")
.arg("--")
.arg(dest)
.arg("envstash send")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| Error::Other(format!("failed to run ssh: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::Other(format!("ssh failed: {stderr}")));
}
Ok(output.stdout)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_allows_normal_host() {
assert!(validate_dest("user@example.com").is_ok());
assert!(validate_dest("example.com").is_ok());
assert!(validate_dest("user@host:2222").is_ok());
}
#[test]
fn validate_rejects_leading_dash() {
assert!(validate_dest("-oProxyCommand=evil").is_err());
}
#[test]
fn validate_rejects_empty() {
assert!(validate_dest("").is_err());
}
#[test]
fn validate_rejects_control_chars() {
assert!(validate_dest("user@host\nexploit").is_err());
assert!(validate_dest("user@host exploit").is_err());
}
}