feat(runtime): add SSH command bridge
test / workspace (push) Successful in 12m10s

This commit is contained in:
Tom You
2026-07-09 00:20:43 -05:00
parent 6e08e0e7bc
commit fb5a02821e
6 changed files with 225 additions and 0 deletions
+1
View File
@@ -1,4 +1,5 @@
pub mod config_store;
pub mod local_terminal;
pub mod pty;
pub mod ssh_runtime;
pub mod wallet_vault;
+152
View File
@@ -0,0 +1,152 @@
use serde::Serialize;
use std::process::Command;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshCommand {
pub host: String,
pub user: Option<String>,
pub port: u16,
pub command: String,
pub timeout: Duration,
}
impl SshCommand {
pub fn new(host: impl Into<String>, command: impl Into<String>) -> Self {
Self {
host: host.into(),
user: None,
port: 22,
command: command.into(),
timeout: Duration::from_secs(10),
}
}
pub fn user(mut self, user: impl Into<String>) -> Self {
self.user = Some(user.into());
self
}
pub fn port(mut self, port: u16) -> Self {
self.port = port;
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn destination(&self) -> String {
match &self.user {
Some(user) if !user.trim().is_empty() => {
format!("{}@{}", user.trim(), self.host.trim())
}
_ => self.host.trim().to_string(),
}
}
pub fn validate(&self) -> Result<(), SshError> {
if self.host.trim().is_empty() {
return Err(SshError::Invalid("ssh host is required".to_string()));
}
if self.command.trim().is_empty() {
return Err(SshError::Invalid("ssh command is required".to_string()));
}
if self.port == 0 {
return Err(SshError::Invalid("ssh port is required".to_string()));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SshCommandOutput {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SshError {
Invalid(String),
Io(String),
}
#[derive(Debug, Clone, Default)]
pub struct SshClient;
impl SshClient {
pub fn build_args(&self, request: &SshCommand) -> Result<Vec<String>, SshError> {
request.validate()?;
Ok(vec![
"-o".to_string(),
"BatchMode=yes".to_string(),
"-o".to_string(),
format!("ConnectTimeout={}", request.timeout.as_secs().max(1)),
"-p".to_string(),
request.port.to_string(),
request.destination(),
request.command.clone(),
])
}
pub fn run(&self, request: SshCommand) -> Result<SshCommandOutput, SshError> {
let args = self.build_args(&request)?;
let output = Command::new("ssh")
.args(args)
.output()
.map_err(|err| SshError::Io(err.to_string()))?;
Ok(SshCommandOutput {
exit_code: output.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ssh_command_builds_batch_mode_args() {
let request = SshCommand::new("example.com", "uptime")
.user("alice")
.port(2222)
.timeout(Duration::from_secs(3));
let args = SshClient::default().build_args(&request).unwrap();
assert_eq!(
args,
vec![
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=3",
"-p",
"2222",
"[email protected]",
"uptime"
]
);
}
#[test]
fn ssh_command_rejects_missing_host_or_command() {
assert_eq!(
SshCommand::new(" ", "uptime").validate().unwrap_err(),
SshError::Invalid("ssh host is required".to_string())
);
assert_eq!(
SshCommand::new("example.com", " ").validate().unwrap_err(),
SshError::Invalid("ssh command is required".to_string())
);
}
#[test]
fn ssh_command_destination_omits_blank_user() {
let request = SshCommand::new("host.local", "true").user(" ");
assert_eq!(request.destination(), "host.local");
}
}