socket_patch_core/utils/process.rs
1//! Subprocess invocation seam shared by the ecosystem crawlers.
2//!
3//! Several crawlers ask an external CLI for a path that's hard to
4//! infer otherwise — `npm root -g`, `gem env gemdir`, `python3 -c
5//! "import site; ..."`, etc. The historical pattern was to embed
6//! `std::process::Command::new(bin).args([...]).output()` directly
7//! inside each helper, which leaves two arms untestable without
8//! installing the binary: the success arm (binary present, stdout
9//! parsed) and the spawn-Err arm (binary missing or unspawnable).
10//!
11//! This module provides a `CommandRunner` trait whose default impl,
12//! `SystemCommandRunner`, performs the real spawn, and whose test
13//! double (`MockCommandRunner` in `tests/common/mod.rs`) maps
14//! `(bin, args)` to canned stdout. Each shell-out helper accepts a
15//! `&dyn CommandRunner` argument so tests can inject the mock;
16//! production callers either build the helper with the default
17//! runner or thread a singleton.
18
19use std::process::{Command, Stdio};
20
21/// Run an external binary with the given args and return its
22/// stdout, trimmed, when the spawn succeeded AND the process exited
23/// with a success status AND stdout is non-empty after trimming.
24///
25/// Returns `None` for any of: spawn failure (binary not on PATH),
26/// non-zero exit status, empty stdout after trim. Stderr is
27/// captured and discarded — the crawlers treat all failures as
28/// "no information", not as errors to surface.
29pub trait CommandRunner: Send + Sync {
30 fn run(&self, bin: &str, args: &[&str]) -> Option<String>;
31}
32
33/// Default runner: spawns the real binary via `std::process::Command`.
34///
35/// Stdin is set to /dev/null so the child can't block waiting for
36/// input. stdout is captured; stderr is captured and dropped (we
37/// don't surface CLI diagnostics — the helpers fall back to other
38/// discovery paths on any failure).
39pub struct SystemCommandRunner;
40
41impl CommandRunner for SystemCommandRunner {
42 fn run(&self, bin: &str, args: &[&str]) -> Option<String> {
43 let output = Command::new(bin)
44 .args(args)
45 .stdin(Stdio::null())
46 .stdout(Stdio::piped())
47 .stderr(Stdio::piped())
48 .output()
49 .ok()?;
50 if !output.status.success() {
51 return None;
52 }
53 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
54 if stdout.is_empty() {
55 None
56 } else {
57 Some(stdout)
58 }
59 }
60}
61
62#[cfg(test)]
63mod tests {
64 use super::*;
65
66 /// Confirm the real runner returns Some for a tiny command we
67 /// know is on every Unix PATH — `echo`. Skipped on Windows where
68 /// `echo` isn't a real binary.
69 #[cfg(unix)]
70 #[test]
71 fn system_runner_returns_stdout_for_real_binary() {
72 let runner = SystemCommandRunner;
73 let out = runner.run("echo", &["hello"]).expect("echo should succeed");
74 assert_eq!(out, "hello");
75 }
76
77 /// Spawn failure → None. The binary name is intentionally one
78 /// that should never be on PATH.
79 #[test]
80 fn system_runner_returns_none_on_spawn_failure() {
81 let runner = SystemCommandRunner;
82 let out = runner.run("definitely-not-a-real-binary-1234567", &[]);
83 assert_eq!(out, None);
84 }
85
86 /// Non-zero exit → None. `false`(1) is in coreutils everywhere.
87 #[cfg(unix)]
88 #[test]
89 fn system_runner_returns_none_on_non_zero_exit() {
90 let runner = SystemCommandRunner;
91 let out = runner.run("false", &[]);
92 assert_eq!(out, None);
93 }
94
95 /// Exit 0 but stdout is empty → None. This is the fourth arm of
96 /// the contract and was previously untested. A successful command
97 /// that prints nothing carries no information for the crawlers.
98 #[cfg(unix)]
99 #[test]
100 fn system_runner_returns_none_on_empty_stdout_despite_success() {
101 let runner = SystemCommandRunner;
102 let out = runner.run("true", &[]);
103 assert_eq!(out, None);
104 }
105
106 /// Exit 0 with whitespace-only stdout → None: the empty check
107 /// happens *after* trimming, so a command that prints only spaces
108 /// and newlines is treated as "no output".
109 #[cfg(unix)]
110 #[test]
111 fn system_runner_treats_whitespace_only_stdout_as_empty() {
112 let runner = SystemCommandRunner;
113 let out = runner.run("sh", &["-c", "printf ' \\t\\n '"]);
114 assert_eq!(out, None);
115 }
116
117 /// Surrounding whitespace is trimmed from a non-empty result, so
118 /// callers that join the value into a path don't get stray
119 /// newlines (e.g. `npm root -g` emits a trailing `\n`).
120 #[cfg(unix)]
121 #[test]
122 fn system_runner_trims_surrounding_whitespace() {
123 let runner = SystemCommandRunner;
124 let out = runner.run("sh", &["-c", "printf ' /some/path \\n'"]);
125 assert_eq!(out.as_deref(), Some("/some/path"));
126 }
127
128 /// stderr never leaks into the result. When stdout is empty but
129 /// the process wrote to stderr and still exited 0, the result is
130 /// None — stderr is captured and dropped, not returned.
131 #[cfg(unix)]
132 #[test]
133 fn system_runner_ignores_stderr_when_stdout_empty() {
134 let runner = SystemCommandRunner;
135 let out = runner.run("sh", &["-c", "printf 'diagnostic' >&2"]);
136 assert_eq!(out, None);
137 }
138
139 /// When a command writes to both streams, only stdout comes back —
140 /// the stderr line must not be appended or interleaved.
141 #[cfg(unix)]
142 #[test]
143 fn system_runner_returns_only_stdout_when_both_streams_used() {
144 let runner = SystemCommandRunner;
145 let out = runner.run("sh", &["-c", "printf 'good\\n'; printf 'bad\\n' >&2"]);
146 assert_eq!(out.as_deref(), Some("good"));
147 }
148
149 /// Every element of `args` is forwarded to the child in order.
150 /// Here `$0` is `sh` and `$1` is `forwarded`; printing `$1` proves
151 /// positional args survive the hop into `Command::args`.
152 #[cfg(unix)]
153 #[test]
154 fn system_runner_forwards_all_args_in_order() {
155 let runner = SystemCommandRunner;
156 let out = runner.run("sh", &["-c", "printf '%s' \"$1\"", "sh", "forwarded"]);
157 assert_eq!(out.as_deref(), Some("forwarded"));
158 }
159}