- runner.rs: timeout 30s sur les commandes capturees (doctor ne pend plus) + pipes stdout/stderr draines dans des threads (anti-deadlock) ; run_untimed() pour les installations longues (npm/cargo/go) — sh() des installateurs l'utilise, les sondes gardent le timeout
- migrate_cmd.rs: rejet des chemins unsafe (path traversal) dans import_bundle
- process.rs: is_running Windows via OpenProcess/GetExitCodeProcess (fiable vs tasklist)
- probe.rs: drain des pipes en threads dans detect_external_version
- web.rs: boucle de traduction i18n (curseur, plus de boucle infinie si la traduction contient {{)
- serve.rs: frames WebSocket RFC 6455 avec vraies longueurs > 125 octets (126/127)
- secrets.rs: index keyring serialise en JSON (cles contenant des virgules)
- sandbox.rs: tests sans fuite de TempDir (plus de mem::forget)
- version 1.1.6, man pages et ROADMAP maj
298 lines
9.0 KiB
Rust
298 lines
9.0 KiB
Rust
//! Command execution abstraction: real process execution for production,
|
|
//! an in-memory recorder for tests and dry runs.
|
|
|
|
use crate::output::Logger;
|
|
use anyhow::{Context, Result};
|
|
use std::collections::{BTreeMap, HashSet};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct RunOutput {
|
|
pub code: i32,
|
|
pub stdout: String,
|
|
pub stderr: String,
|
|
}
|
|
|
|
/// Resolve a command name to an executable path, handling Windows
|
|
/// .cmd/.bat scripts (which std::process::Command cannot launch directly).
|
|
/// Returns the program to spawn and a prefix of arguments to insert before
|
|
/// the caller's arguments (e.g. cmd.exe /C script.cmd).
|
|
pub fn resolve_program(cmd: &str) -> (String, Vec<String>) {
|
|
#[cfg(windows)]
|
|
{
|
|
let path = if cmd.contains('\\') || cmd.contains('/') {
|
|
Some(PathBuf::from(cmd))
|
|
} else {
|
|
which::which(cmd).ok()
|
|
};
|
|
if let Some(p) = path {
|
|
let ext = p
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
if ext == "cmd" || ext == "bat" {
|
|
let comspec = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string());
|
|
return (comspec, vec!["/C".to_string(), p.display().to_string()]);
|
|
}
|
|
return (p.display().to_string(), Vec::new());
|
|
}
|
|
}
|
|
(cmd.to_string(), Vec::new())
|
|
}
|
|
|
|
/// Execute external commands with logging and dry-run support.
|
|
pub trait Runner {
|
|
fn run(
|
|
&self,
|
|
cmd: &str,
|
|
args: &[String],
|
|
env: &BTreeMap<String, String>,
|
|
cwd: Option<&Path>,
|
|
capture: bool,
|
|
) -> Result<RunOutput>;
|
|
|
|
/// Run without the probe timeout: used for legitimately long
|
|
/// operations (package installs, git clones, ...). The default
|
|
/// implementation delegates to run().
|
|
fn run_untimed(
|
|
&self,
|
|
cmd: &str,
|
|
args: &[String],
|
|
env: &BTreeMap<String, String>,
|
|
cwd: Option<&Path>,
|
|
capture: bool,
|
|
) -> Result<RunOutput> {
|
|
self.run(cmd, args, env, cwd, capture)
|
|
}
|
|
|
|
/// Locate a binary on the PATH (like the which command).
|
|
fn which(&self, bin: &str) -> Option<PathBuf>;
|
|
}
|
|
|
|
/// Production runner backed by std::process::Command.
|
|
pub struct SystemRunner<'a> {
|
|
pub dry_run: bool,
|
|
pub verbose: bool,
|
|
pub log: &'a Logger,
|
|
}
|
|
|
|
impl<'a> SystemRunner<'a> {
|
|
pub fn new(dry_run: bool, verbose: bool, log: &'a Logger) -> Self {
|
|
Self {
|
|
dry_run,
|
|
verbose,
|
|
log,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'a> SystemRunner<'a> {
|
|
/// Shared implementation. The timeout applies only to captured
|
|
/// probes; long-running installs pass None via run_untimed().
|
|
fn run_inner(
|
|
&self,
|
|
cmd: &str,
|
|
args: &[String],
|
|
env: &BTreeMap<String, String>,
|
|
cwd: Option<&Path>,
|
|
capture: bool,
|
|
timeout: Option<std::time::Duration>,
|
|
) -> Result<RunOutput> {
|
|
let display = if args.is_empty() {
|
|
cmd.to_string()
|
|
} else {
|
|
format!("{cmd} {}", args.join(" "))
|
|
};
|
|
if self.dry_run {
|
|
self.log.dry(format!("would run: {display}"));
|
|
return Ok(RunOutput::default());
|
|
}
|
|
self.log.cmd(&display);
|
|
let (prog, prefix) = resolve_program(cmd);
|
|
let mut full_args = prefix;
|
|
full_args.extend(args.iter().cloned());
|
|
let mut c = std::process::Command::new(&prog);
|
|
c.args(&full_args);
|
|
c.envs(env);
|
|
if let Some(dir) = cwd {
|
|
c.current_dir(dir);
|
|
}
|
|
if capture {
|
|
c.stdout(std::process::Stdio::piped());
|
|
c.stderr(std::process::Stdio::piped());
|
|
let mut child = c
|
|
.spawn()
|
|
.with_context(|| format!("failed to execute: {display}"))?;
|
|
|
|
let stdout_pipe = child.stdout.take();
|
|
let stderr_pipe = child.stderr.take();
|
|
|
|
let stdout_handle = std::thread::spawn(move || {
|
|
let mut out = Vec::new();
|
|
if let Some(mut pipe) = stdout_pipe {
|
|
let _ = std::io::Read::read_to_end(&mut pipe, &mut out);
|
|
}
|
|
out
|
|
});
|
|
let stderr_handle = std::thread::spawn(move || {
|
|
let mut err = Vec::new();
|
|
if let Some(mut pipe) = stderr_pipe {
|
|
let _ = std::io::Read::read_to_end(&mut pipe, &mut err);
|
|
}
|
|
err
|
|
});
|
|
|
|
let status = if let Some(timeout) = timeout {
|
|
use wait_timeout::ChildExt;
|
|
match child.wait_timeout(timeout) {
|
|
Ok(Some(s)) => s,
|
|
Ok(None) => {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
self.log
|
|
.warn(format!("command timed out after {timeout:?}: {display}"));
|
|
let stdout = String::from_utf8_lossy(&stdout_handle.join().unwrap_or_default())
|
|
.to_string();
|
|
let stderr = String::from_utf8_lossy(&stderr_handle.join().unwrap_or_default())
|
|
.to_string();
|
|
return Ok(RunOutput {
|
|
code: -1,
|
|
stdout,
|
|
stderr,
|
|
});
|
|
}
|
|
Err(_) => {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
return Ok(RunOutput {
|
|
code: -1,
|
|
stdout: String::new(),
|
|
stderr: String::new(),
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
child
|
|
.wait()
|
|
.with_context(|| format!("failed to wait: {display}"))?
|
|
};
|
|
|
|
let stdout_bytes = stdout_handle.join().unwrap_or_default();
|
|
let stderr_bytes = stderr_handle.join().unwrap_or_default();
|
|
let code = status.code().unwrap_or(-1);
|
|
let stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
|
|
let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
|
|
self.log.result(code, &stderr);
|
|
Ok(RunOutput {
|
|
code,
|
|
stdout,
|
|
stderr,
|
|
})
|
|
} else {
|
|
let status = c
|
|
.status()
|
|
.with_context(|| format!("failed to execute: {display}"))?;
|
|
Ok(RunOutput {
|
|
code: status.code().unwrap_or(-1),
|
|
stdout: String::new(),
|
|
stderr: String::new(),
|
|
})
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
impl<'a> Runner for SystemRunner<'a> {
|
|
/// Probes are bounded by a 30 s timeout so a misbehaving process can
|
|
/// never hang doctor/version detection.
|
|
fn run(
|
|
&self,
|
|
cmd: &str,
|
|
args: &[String],
|
|
env: &BTreeMap<String, String>,
|
|
cwd: Option<&Path>,
|
|
capture: bool,
|
|
) -> Result<RunOutput> {
|
|
self.run_inner(
|
|
cmd,
|
|
args,
|
|
env,
|
|
cwd,
|
|
capture,
|
|
Some(std::time::Duration::from_secs(30)),
|
|
)
|
|
}
|
|
|
|
/// Installs and other legitimately long operations run without the
|
|
/// probe timeout (npm/cargo/go installs can take minutes).
|
|
fn run_untimed(
|
|
&self,
|
|
cmd: &str,
|
|
args: &[String],
|
|
env: &BTreeMap<String, String>,
|
|
cwd: Option<&Path>,
|
|
capture: bool,
|
|
) -> Result<RunOutput> {
|
|
self.run_inner(cmd, args, env, cwd, capture, None)
|
|
}
|
|
|
|
fn which(&self, bin: &str) -> Option<PathBuf> {
|
|
which::which(bin).ok()
|
|
}
|
|
}
|
|
|
|
/// In-memory runner for tests: records every invocation, returns success.
|
|
#[derive(Default)]
|
|
pub struct MockRunner {
|
|
pub calls: std::sync::Mutex<Vec<String>>,
|
|
pub found: HashSet<String>,
|
|
pub outputs: std::sync::Mutex<Vec<RunOutput>>,
|
|
}
|
|
|
|
impl MockRunner {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn with_found(mut self, bin: &str) -> Self {
|
|
self.found.insert(bin.to_string());
|
|
self
|
|
}
|
|
|
|
pub fn with_output(mut self, out: RunOutput) -> Self {
|
|
self.outputs.get_mut().unwrap().push(out);
|
|
self
|
|
}
|
|
|
|
pub fn snapshot(&self) -> Vec<String> {
|
|
self.calls.lock().unwrap().clone()
|
|
}
|
|
}
|
|
|
|
impl Runner for MockRunner {
|
|
fn run(
|
|
&self,
|
|
cmd: &str,
|
|
args: &[String],
|
|
_env: &BTreeMap<String, String>,
|
|
_cwd: Option<&Path>,
|
|
_capture: bool,
|
|
) -> Result<RunOutput> {
|
|
self.calls
|
|
.lock()
|
|
.unwrap()
|
|
.push(format!("{cmd} {}", args.join(" ")));
|
|
let out = self.outputs.lock().unwrap().pop();
|
|
Ok(out.unwrap_or_default())
|
|
}
|
|
|
|
fn which(&self, bin: &str) -> Option<PathBuf> {
|
|
if self.found.contains(bin) {
|
|
Some(PathBuf::from(bin))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|