Skip to main content

horto_os_ui_shared/remote/
process.rs

1//! Injectable process runner for OpenSSH / curl / tar (unit-testable).
2
3use crate::error::{HortoError, Result};
4use std::io::Write;
5use std::process::{Command, Stdio};
6
7/// How child stdio is connected.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum StdioMode {
10    /// Pipe stdout/stderr so the parent can read them (probes, downloads).
11    Capture,
12    /// Inherit stdin/stdout/stderr so OpenSSH can prompt on a TTY.
13    Inherit,
14}
15
16/// Captured stdout/stderr and exit status from an external program.
17#[derive(Debug, Clone)]
18pub struct CommandOutput {
19    /// Process exit code (or -1 when the OS status had no code).
20    pub status: i32,
21    /// UTF-8 lossy stdout (empty when [`StdioMode::Inherit`]).
22    pub stdout: String,
23    /// UTF-8 lossy stderr (empty when [`StdioMode::Inherit`]).
24    pub stderr: String,
25}
26
27impl CommandOutput {
28    /// Whether the process exited with status 0.
29    #[must_use]
30    pub const fn success(&self) -> bool {
31        self.status == 0
32    }
33}
34
35/// Runs external programs (real OS or a test double).
36pub trait ProcessRunner {
37    /// Execute `program` with `args`, optional env, and stdio mode.
38    ///
39    /// # Errors
40    ///
41    /// Returns [`crate::HortoError`] when the process cannot be spawned.
42    fn run(
43        &self,
44        program: &str,
45        args: &[&str],
46        env: &[(&str, &str)],
47        stdio: StdioMode,
48    ) -> Result<CommandOutput>;
49
50    /// Like [`Self::run`] with [`StdioMode::Capture`], writing `stdin` to the child.
51    ///
52    /// Default: unsupported (test doubles that never feed stdin can keep the default).
53    ///
54    /// # Errors
55    ///
56    /// Returns [`crate::HortoError`] when spawn/write fails or the runner rejects stdin.
57    fn run_with_stdin(
58        &self,
59        program: &str,
60        args: &[&str],
61        env: &[(&str, &str)],
62        stdin: &[u8],
63    ) -> Result<CommandOutput> {
64        let _ = (program, args, env, stdin);
65        Err(HortoError::msg(
66            "process runner does not support stdin feed",
67        ))
68    }
69}
70
71/// Default runner that spawns real OS processes.
72#[derive(Debug, Default, Clone, Copy)]
73pub struct SystemProcessRunner;
74
75impl ProcessRunner for SystemProcessRunner {
76    fn run(
77        &self,
78        program: &str,
79        args: &[&str],
80        env: &[(&str, &str)],
81        stdio: StdioMode,
82    ) -> Result<CommandOutput> {
83        let mut cmd = Command::new(program);
84        cmd.args(args);
85        for (k, v) in env {
86            cmd.env(k, v);
87        }
88        match stdio {
89            StdioMode::Capture => {
90                cmd.stdin(Stdio::null())
91                    .stdout(Stdio::piped())
92                    .stderr(Stdio::piped());
93                let out = cmd
94                    .output()
95                    .map_err(|e| HortoError::command(program, e.to_string()))?;
96                Ok(CommandOutput {
97                    status: out.status.code().unwrap_or(-1),
98                    stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
99                    stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
100                })
101            }
102            StdioMode::Inherit => {
103                cmd.stdin(Stdio::inherit())
104                    .stdout(Stdio::inherit())
105                    .stderr(Stdio::inherit());
106                let status = cmd
107                    .status()
108                    .map_err(|e| HortoError::command(program, e.to_string()))?;
109                Ok(CommandOutput {
110                    status: status.code().unwrap_or(-1),
111                    stdout: String::new(),
112                    stderr: String::new(),
113                })
114            }
115        }
116    }
117
118    fn run_with_stdin(
119        &self,
120        program: &str,
121        args: &[&str],
122        env: &[(&str, &str)],
123        stdin: &[u8],
124    ) -> Result<CommandOutput> {
125        let mut cmd = Command::new(program);
126        cmd.args(args);
127        for (k, v) in env {
128            cmd.env(k, v);
129        }
130        cmd.stdin(Stdio::piped())
131            .stdout(Stdio::piped())
132            .stderr(Stdio::piped());
133        let mut child = cmd
134            .spawn()
135            .map_err(|e| HortoError::command(program, e.to_string()))?;
136        {
137            let Some(pipe) = child.stdin.as_mut() else {
138                return Err(HortoError::command(program, "stdin pipe missing"));
139            };
140            pipe.write_all(stdin)
141                .map_err(|e| HortoError::command(program, format!("stdin write: {e}")))?;
142        }
143        // Drop stdin so the child sees EOF.
144        drop(child.stdin.take());
145        let out = child
146            .wait_with_output()
147            .map_err(|e| HortoError::command(program, e.to_string()))?;
148        Ok(CommandOutput {
149            status: out.status.code().unwrap_or(-1),
150            stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
151            stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
152        })
153    }
154}
155
156/// One recorded process invocation (tests).
157#[cfg(test)]
158pub type RecordedCall = (String, Vec<String>, Vec<(String, String)>, StdioMode);
159
160/// Scripted runner with a pop-able reply queue per program (tests).
161#[cfg(test)]
162#[derive(Debug, Default)]
163pub struct ScriptedRunner {
164    scripts: std::sync::Mutex<std::collections::HashMap<String, Vec<CommandOutput>>>,
165    /// Recorded process invocations.
166    pub calls: std::sync::Mutex<Vec<RecordedCall>>,
167}
168
169#[cfg(test)]
170impl ScriptedRunner {
171    /// Queue one scripted reply for `program` (FIFO per program name).
172    pub fn push(&self, program: &str, output: CommandOutput) {
173        self.scripts
174            .lock()
175            .expect("lock")
176            .entry(program.to_owned())
177            .or_default()
178            .push(output);
179    }
180
181    /// Successful command with the given stdout and empty stderr.
182    #[must_use]
183    pub fn ok(stdout: &str) -> CommandOutput {
184        CommandOutput {
185            status: 0,
186            stdout: stdout.to_owned(),
187            stderr: String::new(),
188        }
189    }
190
191    /// Failed command with the given exit status and stderr.
192    #[must_use]
193    pub fn fail(status: i32, stderr: &str) -> CommandOutput {
194        CommandOutput {
195            status,
196            stdout: String::new(),
197            stderr: stderr.to_owned(),
198        }
199    }
200}
201
202#[cfg(test)]
203impl ProcessRunner for ScriptedRunner {
204    fn run(
205        &self,
206        program: &str,
207        args: &[&str],
208        env: &[(&str, &str)],
209        stdio: StdioMode,
210    ) -> Result<CommandOutput> {
211        self.calls.lock().expect("lock").push((
212            program.to_owned(),
213            args.iter().map(|s| (*s).to_owned()).collect(),
214            env.iter()
215                .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
216                .collect(),
217            stdio,
218        ));
219        let mut map = self.scripts.lock().expect("lock");
220        let queue = map.get_mut(program).ok_or_else(|| {
221            HortoError::msg(format!("scripted runner: unexpected program {program}"))
222        })?;
223        if queue.is_empty() {
224            return Err(HortoError::msg(format!(
225                "scripted runner: no more replies for {program}"
226            )));
227        }
228        let out = queue.remove(0);
229        drop(map);
230        Ok(out)
231    }
232
233    fn run_with_stdin(
234        &self,
235        program: &str,
236        args: &[&str],
237        env: &[(&str, &str)],
238        stdin: &[u8],
239    ) -> Result<CommandOutput> {
240        let _ = stdin;
241        self.run(program, args, env, StdioMode::Capture)
242    }
243}
244
245#[cfg(test)]
246mod system_tests {
247    use super::*;
248
249    #[test]
250    fn command_output_success() {
251        assert!(CommandOutput {
252            status: 0,
253            stdout: String::new(),
254            stderr: String::new(),
255        }
256        .success());
257        assert!(!CommandOutput {
258            status: 1,
259            stdout: String::new(),
260            stderr: String::new(),
261        }
262        .success());
263    }
264
265    #[test]
266    fn system_runner_capture_echo() {
267        let out = SystemProcessRunner
268            .run("echo", &["hello-remote"], &[], StdioMode::Capture)
269            .unwrap();
270        assert!(out.success());
271        assert!(out.stdout.contains("hello-remote"));
272    }
273
274    #[test]
275    fn system_runner_inherit_true() {
276        let out = SystemProcessRunner
277            .run("true", &[], &[], StdioMode::Inherit)
278            .unwrap();
279        assert!(out.success());
280        assert_eq!(out.stdout, "");
281    }
282
283    #[test]
284    fn system_runner_run_with_stdin_cat() {
285        let out = SystemProcessRunner
286            .run_with_stdin("/bin/cat", &[], &[("LC_ALL", "C")], b"stdin-bytes")
287            .unwrap();
288        assert!(out.success());
289        assert!(out.stdout.contains("stdin-bytes"));
290    }
291
292    #[test]
293    fn system_runner_run_with_stdin_missing_program() {
294        let err = SystemProcessRunner
295            .run_with_stdin("horto-os-ui-definitely-missing-bin-xyz", &[], &[], b"x")
296            .unwrap_err();
297        assert!(err.to_string().contains("horto-os-ui-definitely-missing"));
298    }
299
300    #[test]
301    fn system_runner_missing_program() {
302        let err = SystemProcessRunner
303            .run(
304                "horto-os-ui-definitely-missing-bin-xyz",
305                &[],
306                &[],
307                StdioMode::Capture,
308            )
309            .unwrap_err();
310        assert!(err.to_string().contains("horto-os-ui-definitely-missing"));
311    }
312
313    #[test]
314    fn scripted_run_with_stdin_delegates_to_run() {
315        let runner = ScriptedRunner::default();
316        runner.push("ssh", ScriptedRunner::ok("fed"));
317        let out = runner
318            .run_with_stdin("ssh", &["-n"], &[("A", "1")], b"secret")
319            .unwrap();
320        assert!(out.success());
321        assert_eq!(out.stdout, "fed");
322        let calls = runner.calls.lock().expect("lock");
323        assert_eq!(calls.len(), 1);
324        assert_eq!(calls[0].0, "ssh");
325        assert_eq!(calls[0].3, StdioMode::Capture);
326        drop(calls);
327    }
328
329    #[test]
330    fn default_run_with_stdin_rejects() {
331        struct OnlyRun;
332        impl ProcessRunner for OnlyRun {
333            fn run(
334                &self,
335                _program: &str,
336                _args: &[&str],
337                _env: &[(&str, &str)],
338                _stdio: StdioMode,
339            ) -> Result<CommandOutput> {
340                Ok(ScriptedRunner::ok(""))
341            }
342        }
343        let err = OnlyRun.run_with_stdin("ssh", &[], &[], b"x").unwrap_err();
344        assert!(err.to_string().contains("does not support stdin"));
345    }
346
347    #[test]
348    fn scripted_unexpected_and_exhausted() {
349        let runner = ScriptedRunner::default();
350        let err = runner
351            .run("nope", &[], &[], StdioMode::Capture)
352            .unwrap_err();
353        assert!(err.to_string().contains("unexpected"));
354        runner.push("once", ScriptedRunner::ok(""));
355        runner.run("once", &[], &[], StdioMode::Capture).unwrap();
356        let err = runner
357            .run("once", &[], &[], StdioMode::Capture)
358            .unwrap_err();
359        assert!(err.to_string().contains("no more replies"));
360    }
361}