Skip to main content

fhd/
exec.rs

1//! Command construction and process lifecycle for remote commands.
2//!
3//! Everything here turns a `RunPayload` argument vector into an actual
4//! OS process: shell selection, argument escaping, toolchain environment
5//! wiring, and process-group termination on cancellation (AGENTS.md §3.4).
6
7use std::collections::HashMap;
8use std::path::Path;
9use std::process::Stdio;
10use tokio::process::Command;
11
12#[cfg(windows)]
13pub(crate) fn shell_escape(arg: &str) -> String {
14    if arg.is_empty() {
15        return "\"\"".to_string();
16    }
17    if arg.chars().all(|c| {
18        c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | '\\' | ':' | '=' | '@')
19    }) {
20        return arg.to_string();
21    }
22    format!("\"{}\"", arg.replace('"', "\\\""))
23}
24
25#[cfg(not(windows))]
26pub(crate) fn shell_escape(arg: &str) -> String {
27    if arg.is_empty() {
28        return "''".to_string();
29    }
30    if arg
31        .chars()
32        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':' | '=' | '@'))
33    {
34        return arg.to_string();
35    }
36    format!("'{}'", arg.replace('\'', "'\\''"))
37}
38
39pub fn wrap_command_with_toolchain(
40    cmd_str: &str,
41    toolchain: Option<&HashMap<String, String>>,
42) -> String {
43    let Some(toolchain) = toolchain else {
44        return cmd_str.to_string();
45    };
46    if toolchain.is_empty() {
47        return cmd_str.to_string();
48    }
49
50    #[cfg(not(unix))]
51    {
52        let _ = toolchain;
53        cmd_str.to_string()
54    }
55
56    #[cfg(unix)]
57    {
58        let mut prefixes: Vec<String> = Vec::new();
59        for (lang, ver) in toolchain {
60            let l = lang.to_ascii_lowercase();
61            match l.as_str() {
62                "node" | "nodejs" => {
63                    prefixes.push(format!(
64                        "(export NVM_DIR=\"$HOME/.nvm\"; [ -s \"$NVM_DIR/nvm.sh\" ] && \\. \"$NVM_DIR/nvm.sh\" && nvm use {} >/dev/null 2>&1) || (which fnm >/dev/null 2>&1 && eval \"$(fnm env)\" && fnm use {} >/dev/null 2>&1) || true",
65                        ver, ver
66                    ));
67                }
68                "go" | "golang" => {
69                    prefixes.push(format!(
70                        "(which goenv >/dev/null 2>&1 && export GOENV_VERSION={} && eval \"$(goenv init -)\") || true",
71                        ver
72                    ));
73                }
74                "python" | "pyenv" => {
75                    prefixes.push(
76                        "(which pyenv >/dev/null 2>&1 && eval \"$(pyenv init -)\") || true"
77                            .to_string(),
78                    );
79                }
80                _ => {}
81            }
82        }
83
84        if prefixes.is_empty() {
85            cmd_str.to_string()
86        } else {
87            format!("{} && {}", prefixes.join(" && "), cmd_str)
88        }
89    }
90}
91
92pub fn apply_toolchain_env(cmd: &mut Command, toolchain: Option<&HashMap<String, String>>) {
93    if let Some(tc) = toolchain {
94        for (lang, ver) in tc {
95            let l = lang.to_ascii_lowercase();
96            match l.as_str() {
97                "rust" | "rustup" => {
98                    cmd.env("RUSTUP_TOOLCHAIN", ver);
99                }
100                "python" | "pyenv" => {
101                    cmd.env("PYENV_VERSION", ver);
102                }
103                "node" | "nodejs" => {
104                    cmd.env("NODE_VERSION", ver);
105                }
106                _ => {}
107            }
108            let env_key = format!("FARHAND_TOOLCHAIN_{}", l.to_ascii_uppercase());
109            cmd.env(env_key, ver);
110        }
111    }
112}
113
114pub fn apply_toolchain_pty(
115    cmd_builder: &mut portable_pty::CommandBuilder,
116    toolchain: Option<&HashMap<String, String>>,
117) {
118    if let Some(tc) = toolchain {
119        for (lang, ver) in tc {
120            let l = lang.to_ascii_lowercase();
121            match l.as_str() {
122                "rust" | "rustup" => {
123                    cmd_builder.env("RUSTUP_TOOLCHAIN", ver);
124                }
125                "python" | "pyenv" => {
126                    cmd_builder.env("PYENV_VERSION", ver);
127                }
128                "node" | "nodejs" => {
129                    cmd_builder.env("NODE_VERSION", ver);
130                }
131                _ => {}
132            }
133            let env_key = format!("FARHAND_TOOLCHAIN_{}", l.to_ascii_uppercase());
134            cmd_builder.env(env_key, ver);
135        }
136    }
137}
138
139/// Parse a custom shell invocation (e.g. `/bin/sh -c`) into argv tokens.
140/// Returns `None` for empty or whitespace-only input — a misconfiguration the
141/// daemon rejects at startup; library callers fall back to the default shell.
142pub fn parse_custom_shell(shell: &str) -> Option<Vec<String>> {
143    let tokens: Vec<String> = shell.split_whitespace().map(str::to_string).collect();
144    if tokens.is_empty() {
145        None
146    } else {
147        Some(tokens)
148    }
149}
150
151pub fn build_shell_command(
152    cwd: &Path,
153    argv: &[String],
154    custom_shell: Option<&str>,
155    toolchain: Option<&HashMap<String, String>>,
156) -> Command {
157    let joined_cmd = argv
158        .iter()
159        .map(|a| shell_escape(a))
160        .collect::<Vec<_>>()
161        .join(" ");
162    let wrapped_cmd = wrap_command_with_toolchain(&joined_cmd, toolchain);
163
164    let mut cmd = if let Some(shell_override) = custom_shell {
165        let shell_tokens = parse_custom_shell(shell_override)
166            .unwrap_or_else(|| vec!["/bin/sh".to_string(), "-c".to_string()]);
167        let mut c = Command::new(&shell_tokens[0]);
168        for part in &shell_tokens[1..] {
169            c.arg(part);
170        }
171        c.arg(&wrapped_cmd);
172        c
173    } else if cfg!(windows) {
174        let mut c = Command::new("cmd.exe");
175        #[cfg(windows)]
176        c.raw_arg(format!("/C \"{}\"", wrapped_cmd));
177        #[cfg(not(windows))]
178        c.arg("/C").arg(&wrapped_cmd);
179        c
180    } else {
181        let mut c = Command::new("/bin/sh");
182        c.arg("-c").arg(&wrapped_cmd);
183        c
184    };
185
186    cmd.current_dir(cwd);
187    cmd.stdout(Stdio::piped());
188    cmd.stderr(Stdio::piped());
189
190    #[cfg(unix)]
191    cmd.process_group(0);
192
193    cmd
194}
195
196pub fn build_raw_shell_command(
197    cwd: &Path,
198    raw_cmd: &str,
199    custom_shell: Option<&str>,
200    toolchain: Option<&HashMap<String, String>>,
201) -> Command {
202    let wrapped_cmd = wrap_command_with_toolchain(raw_cmd, toolchain);
203    let mut cmd = if let Some(shell_override) = custom_shell {
204        let shell_tokens = parse_custom_shell(shell_override)
205            .unwrap_or_else(|| vec!["/bin/sh".to_string(), "-c".to_string()]);
206        let mut c = Command::new(&shell_tokens[0]);
207        for part in &shell_tokens[1..] {
208            c.arg(part);
209        }
210        c.arg(&wrapped_cmd);
211        c
212    } else if cfg!(windows) {
213        let mut c = Command::new("cmd.exe");
214        #[cfg(windows)]
215        c.raw_arg(format!("/C \"{}\"", wrapped_cmd));
216        #[cfg(not(windows))]
217        c.arg("/C").arg(&wrapped_cmd);
218        c
219    } else {
220        let mut c = Command::new("/bin/sh");
221        c.arg("-c").arg(&wrapped_cmd);
222        c
223    };
224
225    cmd.current_dir(cwd);
226    cmd.stdout(Stdio::piped());
227    cmd.stderr(Stdio::piped());
228
229    #[cfg(unix)]
230    cmd.process_group(0);
231
232    cmd
233}
234
235/// Terminate a remote command's whole process group (AGENTS.md §3.4).
236///
237/// SIGTERM first, then SIGKILL after 3s: the negative pid signals the
238/// process *group*, which is why `build_command` sets `process_group(0)` —
239/// that makes the child a group leader whose pgid equals its pid, so the
240/// compiler tree (rustc → linker → build script) dies together instead of
241/// leaving orphans behind.
242#[allow(unsafe_code)] // FFI: kill(2) on our own child's process group — SAFETY comments inside.
243pub async fn kill_process_group(child: &mut tokio::process::Child) {
244    #[cfg(unix)]
245    if let Some(pid) = child.id() {
246        // SAFETY: `pid` is the id of a child this daemon spawned with
247        // `process_group(0)`, so `-pid` addresses that child's process group
248        // and can only signal processes we own. `kill` is async-signal-safe
249        // and has no memory preconditions.
250        unsafe {
251            libc::kill(-(pid as i32), libc::SIGTERM);
252        }
253        tokio::select! {
254            _ = child.wait() => {}
255            _ = tokio::time::sleep(std::time::Duration::from_secs(3)) => {
256                // SAFETY: as above; the process group may still contain
257                // descendants that ignored SIGTERM.
258                unsafe {
259                    libc::kill(-(pid as i32), libc::SIGKILL);
260                }
261            }
262        }
263    }
264    #[cfg(windows)]
265    if let Some(pid) = child.id() {
266        let _ = tokio::process::Command::new("taskkill")
267            .args(["/F", "/T", "/PID", &pid.to_string()])
268            .output()
269            .await;
270        let _ = child.kill().await;
271    }
272    #[cfg(not(any(unix, windows)))]
273    {
274        let _ = child.kill().await;
275    }
276}
277
278pub fn resolve_shell_executable(requested: &str) -> String {
279    if requested != "$SHELL" && !requested.is_empty() {
280        if Path::new(requested).is_file() {
281            return requested.to_string();
282        }
283        if !requested.contains('/') && !requested.contains('\\') {
284            for dir in &["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"] {
285                let candidate = Path::new(dir).join(requested);
286                if candidate.is_file() {
287                    return candidate.to_string_lossy().to_string();
288                }
289            }
290        }
291    }
292
293    if let Ok(sh) = std::env::var("SHELL") {
294        if Path::new(&sh).is_file() {
295            return sh;
296        }
297    }
298    for candidate in &[
299        "/bin/zsh",
300        "/bin/bash",
301        "/usr/bin/zsh",
302        "/usr/bin/bash",
303        "/bin/sh",
304    ] {
305        if Path::new(candidate).is_file() {
306            return candidate.to_string();
307        }
308    }
309    #[cfg(windows)]
310    {
311        "powershell.exe".to_string()
312    }
313    #[cfg(not(windows))]
314    {
315        "/bin/sh".to_string()
316    }
317}