1use std::sync::{Arc, Mutex};
2
3use anyhow::{Context, Result, anyhow, bail};
4#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
5use std::process::Command as ProcessCommand;
6#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
7use std::process::Stdio;
8
9use oxdock_fs::PolicyPath;
10
11use crate::child::ChildHandle;
12use crate::contract::{
13 BackgroundHandle, CommandContext, CommandMode, CommandOptions, CommandResult, CommandStderr,
14 CommandStdout, ProcessManager, SharedInput, SharedOutput,
15};
16use crate::shell::shell_cmd;
17
18#[derive(Clone, Default)]
20#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
21pub struct ShellProcessManager;
22
23impl ProcessManager for ShellProcessManager {
24 type Handle = ChildHandle;
25
26 #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
27 fn run_command(
28 &mut self,
29 ctx: &CommandContext,
30 script: &str,
31 options: CommandOptions,
32 ) -> Result<CommandResult<Self::Handle>> {
33 if std::env::var_os("OXBOOK_DEBUG").is_some() {
34 eprintln!("oxbook run_command: {script}");
35 }
36 let mut command = shell_cmd(script);
37 apply_ctx(&mut command, ctx);
38 let CommandOptions {
39 mode,
40 stdin,
41 stdout,
42 stderr,
43 } = options;
44
45 let (stdout_stream, capture_buf) = match stdout {
46 CommandStdout::Inherit => (None, None),
47 CommandStdout::Stream(stream) => (Some(stream), None),
48 CommandStdout::Capture => {
49 if matches!(mode, CommandMode::Background) {
50 bail!("cannot capture stdout for background command");
51 }
52 let buf = Arc::new(Mutex::new(Vec::new()));
53 let writer: SharedOutput = buf.clone();
54 (Some(writer), Some(buf))
55 }
56 };
57
58 let stderr_stream = match stderr {
59 CommandStderr::Inherit => None,
60 CommandStderr::Stream(stream) => Some(stream),
61 };
62
63 let need_null_stdin = stdin.is_none();
64 if need_null_stdin {
65 command.stdin(Stdio::null());
67 }
68 let desc = format!("{:?}", command);
69
70 match mode {
71 CommandMode::Foreground => {
72 let mut handle =
73 spawn_child_with_streams(&mut command, stdin, stdout_stream, stderr_stream)?;
74 let status = handle
75 .wait()
76 .with_context(|| format!("failed to run {desc}"))?;
77 if !status.success() {
78 bail!("command {desc} failed with status {}", status);
79 }
80 if let Some(buf) = capture_buf {
81 let mut guard = buf.lock().map_err(|_| anyhow!("capture stdout poisoned"))?;
82 return Ok(CommandResult::Captured(std::mem::take(&mut *guard)));
83 }
84 Ok(CommandResult::Completed)
85 }
86 CommandMode::Background => {
87 let handle =
88 spawn_child_with_streams(&mut command, stdin, stdout_stream, stderr_stream)?;
89 Ok(CommandResult::Background(handle))
90 }
91 }
92 }
93}
94
95#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
96fn apply_ctx(command: &mut ProcessCommand, ctx: &CommandContext) {
97 let cwd_path: std::borrow::Cow<std::path::Path> = match ctx.cwd() {
113 PolicyPath::Guarded(p) => oxdock_fs::command_path(p),
114 PolicyPath::Unguarded(p) => std::borrow::Cow::Borrowed(p.as_path()),
115 };
116 command.current_dir(cwd_path);
117 command.envs(ctx.envs().as_ref());
118 if let Some(val) = ctx.envs().get("CARGO_TARGET_DIR") {
119 command.env("CARGO_TARGET_DIR", val);
120 } else {
121 command.env(
122 "CARGO_TARGET_DIR",
123 oxdock_fs::command_path(ctx.cargo_target_dir()).into_owned(),
124 );
125 }
126}
127
128#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
129fn spawn_child_with_streams(
130 cmd: &mut ProcessCommand,
131 stdin: Option<SharedInput>,
132 stdout: Option<SharedOutput>,
133 stderr: Option<SharedOutput>,
134) -> Result<ChildHandle> {
135 if stdin.is_some() {
136 cmd.stdin(Stdio::piped());
137 }
138 if stdout.is_some() {
139 cmd.stdout(Stdio::piped());
140 }
141 if stderr.is_some() {
142 cmd.stderr(Stdio::piped());
143 }
144
145 let mut child = cmd
146 .spawn()
147 .with_context(|| format!("failed to spawn {:?}", cmd))?;
148 let mut io_threads = Vec::new();
149
150 if let Some(stdin_stream) = stdin
151 && let Some(mut child_stdin) = child.stdin.take()
152 {
153 let thread = std::thread::spawn(move || {
154 if let Ok(mut guard) = stdin_stream.lock() {
155 let _ = std::io::copy(&mut *guard, &mut child_stdin);
156 }
157 });
158 io_threads.push(thread);
159 }
160
161 if let Some(stdout_stream) = stdout
162 && let Some(mut child_stdout) = child.stdout.take()
163 {
164 let stream_clone = stdout_stream.clone();
165 let thread = std::thread::spawn(move || {
166 let mut buf = [0u8; 1024];
167 loop {
168 match std::io::Read::read(&mut child_stdout, &mut buf) {
169 Ok(0) => break,
170 Ok(n) => {
171 if let Ok(mut guard) = stream_clone.lock() {
172 if std::io::Write::write_all(&mut *guard, &buf[..n]).is_err() {
173 break;
174 }
175 let _ = std::io::Write::flush(&mut *guard);
176 }
177 }
178 Err(_) => break,
179 }
180 }
181 });
182 io_threads.push(thread);
183 }
184
185 if let Some(stderr_stream) = stderr
186 && let Some(mut child_stderr) = child.stderr.take()
187 {
188 let stream_clone = stderr_stream.clone();
189 let thread = std::thread::spawn(move || {
190 let mut buf = [0u8; 1024];
191 loop {
192 match std::io::Read::read(&mut child_stderr, &mut buf) {
193 Ok(0) => break,
194 Ok(n) => {
195 if let Ok(mut guard) = stream_clone.lock() {
196 if std::io::Write::write_all(&mut *guard, &buf[..n]).is_err() {
197 break;
198 }
199 let _ = std::io::Write::flush(&mut *guard);
200 }
201 }
202 Err(_) => break,
203 }
204 }
205 });
206 io_threads.push(thread);
207 }
208
209 Ok(ChildHandle::new(child, io_threads))
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use oxdock_fs::GuardedPath;
216 use std::collections::HashMap;
217
218 fn make_ctx(envs: &[(&str, &str)]) -> (oxdock_fs::GuardedTempDir, CommandContext) {
219 let temp = GuardedPath::tempdir().expect("tempdir");
220 let guard = temp.as_guarded_path().clone();
221 let cwd: PolicyPath = guard.clone().into();
222 let map: HashMap<String, String> = envs
223 .iter()
224 .map(|(key, value)| (key.to_string(), value.to_string()))
225 .collect();
226 let ctx = CommandContext::from_map(&cwd, &map, &guard, &guard, &guard);
227 (temp, ctx)
228 }
229
230 #[test]
231 fn background_capture_stdout_bails_without_spawning() {
232 let (_temp, ctx) = make_ctx(&[]);
233 let mut pm = ShellProcessManager;
234 let options = CommandOptions {
235 mode: CommandMode::Background,
236 stdout: CommandStdout::Capture,
237 ..Default::default()
238 };
239 let err = match pm.run_command(&ctx, "echo hi", options) {
240 Err(err) => err,
241 Ok(_) => panic!("background capture must bail"),
242 };
243 assert!(
244 err.to_string().contains("cannot capture stdout"),
245 "unexpected error: {err}"
246 );
247 }
248
249 #[cfg_attr(
250 miri,
251 ignore = "spawns processes; Miri does not support process execution"
252 )]
253 #[test]
254 fn foreground_capture_returns_child_stdout_bytes() {
255 let (_temp, ctx) = make_ctx(&[]);
256 let mut pm = ShellProcessManager;
257 let options = CommandOptions {
258 stdout: CommandStdout::Capture,
259 ..Default::default()
260 };
261 match pm
262 .run_command(&ctx, "echo hello-capture", options)
263 .expect("run")
264 {
265 CommandResult::Captured(bytes) => {
266 let out = String::from_utf8_lossy(&bytes);
267 assert!(out.contains("hello-capture"), "captured: {out}");
268 }
269 CommandResult::Completed => panic!("expected Captured, got Completed"),
270 CommandResult::Background(_) => panic!("expected Captured, got Background"),
271 }
272 }
273
274 fn large_output_script() -> &'static str {
275 #[cfg(windows)]
276 {
277 "for /l %i in (1,1,20000) do @echo 0123456789abcdef"
278 }
279 #[cfg(not(windows))]
280 {
281 "i=0; while [ $i -lt 20000 ]; do echo 0123456789abcdef; i=$((i+1)); done"
282 }
283 }
284
285 #[cfg_attr(
286 miri,
287 ignore = "spawns processes; Miri does not support process execution"
288 )]
289 #[test]
290 fn streams_large_stdout_through_shared_output_without_deadlock() {
291 let (_temp, ctx) = make_ctx(&[]);
292 let mut pm = ShellProcessManager;
293 let buffer = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
294 let options = CommandOptions {
295 stdout: CommandStdout::Stream(buffer.clone()),
296 ..Default::default()
297 };
298 pm.run_command(&ctx, large_output_script(), options)
299 .expect("run");
300 let bytes = buffer.lock().expect("buffer lock").len();
301 assert!(bytes >= 300_000, "streamed only {bytes} bytes");
304 }
305
306 #[cfg_attr(
307 miri,
308 ignore = "spawns processes; Miri does not support process execution"
309 )]
310 #[test]
311 fn foreground_stdin_is_piped_through_copy_thread() {
312 let (_temp, ctx) = make_ctx(&[]);
313 let mut pm = ShellProcessManager;
314 #[cfg(windows)]
316 let input: &[u8] = b"b\r\na\r\n";
317 #[cfg(not(windows))]
318 let input: &[u8] = b"b\na\n";
319 let payload: SharedInput =
320 std::sync::Arc::new(std::sync::Mutex::new(std::io::Cursor::new(input.to_vec())));
321 let options = CommandOptions {
322 stdin: Some(payload),
323 stdout: CommandStdout::Capture,
324 ..Default::default()
325 };
326 match pm.run_command(&ctx, "sort", options).expect("run") {
328 CommandResult::Captured(bytes) => {
329 assert!(bytes.starts_with(b"a"), "sorted output: {:?}", bytes);
330 assert!(windows_compatible_contains(&bytes, b"b"));
331 }
332 CommandResult::Completed => panic!("expected Captured, got Completed"),
333 CommandResult::Background(_) => panic!("expected Captured, got Background"),
334 }
335 }
336
337 fn windows_compatible_contains(haystack: &[u8], needle: &[u8]) -> bool {
338 haystack.windows(needle.len()).any(|w| w == needle)
339 }
340
341 #[test]
342 #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
343 fn apply_ctx_sets_cwd_and_cargo_target_dir_precedence() {
344 let (temp_a, ctx_a) = make_ctx(&[]);
347 let expected_default = oxdock_fs::command_path(ctx_a.cargo_target_dir())
348 .to_string_lossy()
349 .into_owned();
350 let mut cmd = ProcessCommand::new("prog");
351 apply_ctx(&mut cmd, &ctx_a);
352 let envs_a: HashMap<String, String> = cmd
353 .get_envs()
354 .map(|(k, v)| {
355 (
356 k.to_string_lossy().into_owned(),
357 v.map(|value| value.to_string_lossy().into_owned())
358 .unwrap_or_default(),
359 )
360 })
361 .collect();
362 assert_eq!(envs_a.get("CARGO_TARGET_DIR"), Some(&expected_default));
363 drop(temp_a);
364
365 let (temp_b, ctx_b) = make_ctx(&[("CARGO_TARGET_DIR", "custom-target"), ("FOO", "bar")]);
368 let mut cmd = ProcessCommand::new("prog");
369 apply_ctx(&mut cmd, &ctx_b);
370 let envs_b: HashMap<String, String> = cmd
371 .get_envs()
372 .map(|(k, v)| {
373 (
374 k.to_string_lossy().into_owned(),
375 v.map(|value| value.to_string_lossy().into_owned())
376 .unwrap_or_default(),
377 )
378 })
379 .collect();
380 assert_eq!(
381 envs_b.get("CARGO_TARGET_DIR").map(String::as_str),
382 Some("custom-target")
383 );
384 assert_eq!(envs_b.get("FOO").map(String::as_str), Some("bar"));
385 drop(temp_b);
386 }
387
388 #[test]
389 #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
390 fn apply_ctx_sets_working_directory_from_guarded_cwd() {
391 let (_temp, ctx) = make_ctx(&[]);
392 let mut cmd = ProcessCommand::new("prog");
393 apply_ctx(&mut cmd, &ctx);
394 let expected = oxdock_fs::command_path(match ctx.cwd() {
395 PolicyPath::Guarded(guarded) => guarded,
396 PolicyPath::Unguarded(_) => panic!("expected guarded cwd"),
397 });
398 assert_eq!(cmd.get_current_dir(), Some(expected.as_ref()));
399 }
400}