fix(client): strip shell.rs to bare minimum - no chcp, no wrapper, just cmd /D /C
This commit is contained in:
parent
07d758a631
commit
0e8f2b11e8
1 changed files with 27 additions and 99 deletions
|
|
@ -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())),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue