fix(client): strip shell.rs to bare minimum - no chcp, no wrapper, just cmd /D /C

This commit is contained in:
Helios Agent 2026-03-03 14:58:11 +01:00
parent 07d758a631
commit 0e8f2b11e8
No known key found for this signature in database
GPG key ID: C8259547CD8309B5

View file

@ -1,123 +1,51 @@
/// Shell execution — each command runs in its own fresh process.
/// This means no persistent state between commands (no cd carry-over),
/// but it is rock-solid: a crashed command never affects future commands.
///
/// Output bytes are decoded with `from_utf8_lossy` so no encoding mismatch
/// can ever return an error.
/// No persistent state, no sentinel logic, no encoding tricks.
/// stdout/stderr are captured as raw bytes and decoded with from_utf8_lossy.
use std::time::Duration;
use tokio::process::Command;
const TIMEOUT_MS: u64 = 30_000;
pub struct PersistentShell {
/// On Windows we keep track of the current working directory so callers
/// can still do `cd` and have it persist across commands.
#[cfg(windows)]
pub cwd: Option<String>,
}
pub struct PersistentShell;
impl PersistentShell {
pub fn new() -> Self {
Self {
#[cfg(windows)]
cwd: None,
}
}
pub fn new() -> Self { Self }
/// Run `command` in a fresh child process.
/// Always returns `Ok`; errors are encoded in the `String` fields so the
/// caller can forward them to the relay server.
pub async fn run(&mut self, command: &str) -> Result<(String, String, i32), String> {
let timeout = Duration::from_millis(TIMEOUT_MS);
#[cfg(windows)]
let result = self.run_windows(command, timeout).await;
#[cfg(not(windows))]
let result = run_unix(command, timeout).await;
result
}
#[cfg(windows)]
async fn run_windows(&mut self, command: &str, timeout: Duration) -> Result<(String, String, i32), String> {
// Detect `cd <dir>` — keep the new directory for subsequent commands.
let trimmed = command.trim();
if let Some(rest) = trimmed.strip_prefix("cd").and_then(|r| {
let r = r.trim();
if r.is_empty() { None } else { Some(r) }
}) {
// Resolve the path via a quick subprocess, then store it.
let target = rest.to_string();
let cwd = self.cwd.clone();
let check = spawn_cmd_windows(&format!("cd /D \"{target}\" && cd"), cwd, timeout).await;
match check {
Ok((stdout, _, code)) if code == 0 => {
let new_dir = stdout.lines().last().unwrap_or("").trim().to_string();
if !new_dir.is_empty() {
self.cwd = Some(new_dir.clone());
return Ok((format!("Changed directory to {new_dir}\n"), String::new(), 0));
}
return Ok((String::new(), String::new(), 0));
}
Ok((_, stderr, code)) => {
return Ok((String::new(), stderr, code));
}
Err(e) => return Err(e),
}
{
// /D = skip AutoRun registry (no user `clear` hooks)
// /C = run command and exit
let mut cmd = Command::new("cmd.exe");
cmd.args(["/D", "/C", command]);
run_captured(cmd, Duration::from_millis(TIMEOUT_MS)).await
}
#[cfg(not(windows))]
{
let mut cmd = Command::new("sh");
cmd.args(["-c", command]);
run_captured(cmd, Duration::from_millis(TIMEOUT_MS)).await
}
spawn_cmd_windows(command, self.cwd.clone(), timeout).await
}
}
#[cfg(windows)]
async fn spawn_cmd_windows(
command: &str,
cwd: Option<String>,
timeout: Duration,
) -> Result<(String, String, i32), String> {
// Use cmd.exe with UTF-8 codepage so powershell / unicode output works.
let full_cmd = format!("chcp 65001 >nul 2>&1 && {command}");
let mut cmd = Command::new("cmd.exe");
// /D disables AutoRun registry commands (prevents user's `clear` or other
// startup hooks from wiping the client's terminal output).
cmd.args(["/D", "/C", &full_cmd]);
if let Some(ref dir) = cwd {
cmd.current_dir(dir);
}
run_with_timeout(cmd, timeout).await
}
#[cfg(not(windows))]
async fn run_unix(command: &str, timeout: Duration) -> Result<(String, String, i32), String> {
let mut cmd = Command::new("sh");
cmd.args(["-c", command]);
run_with_timeout(cmd, timeout).await
}
async fn run_with_timeout(
async fn run_captured(
mut cmd: Command,
timeout: Duration,
) -> Result<(String, String, i32), String> {
// Spawn and wait with timeout.
let child = cmd
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let child = cmd.spawn()
.map_err(|e| format!("Failed to spawn process: {e}"))?;
match tokio::time::timeout(timeout, child.wait_with_output()).await {
Ok(Ok(output)) => {
// from_utf8_lossy never fails — replaces invalid bytes with U+FFFD.
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
let exit_code = output.status.code().unwrap_or(-1);
Ok((stdout, stderr, exit_code))
}
Ok(Ok(out)) => Ok((
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.code().unwrap_or(-1),
)),
Ok(Err(e)) => Err(format!("Process wait failed: {e}")),
Err(_) => Err(format!("Command timed out after {}ms", timeout.as_millis())),
Err(_) => Err(format!("Command timed out after {}ms", timeout.as_millis())),
}
}