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 CommandStdin, CommandStdout, PROCESS_DEBUG_ENV_VAR, ProcessManager, SharedInput, SharedOutput,
15};
16use crate::shell::{direct_cmd, 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(PROCESS_DEBUG_ENV_VAR).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 run_prepared(&mut command, mode, stdin, stdout, stderr)
45 }
46
47 #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
48 fn run_argv(
49 &mut self,
50 ctx: &CommandContext,
51 argv: &[String],
52 options: CommandOptions,
53 ) -> Result<CommandResult<Self::Handle>> {
54 if std::env::var_os(PROCESS_DEBUG_ENV_VAR).is_some() {
55 eprintln!("oxbook run_argv: {argv:?}");
56 }
57 let mut command = direct_cmd(argv)?;
58 apply_ctx(&mut command, ctx);
59 let CommandOptions {
60 mode,
61 stdin,
62 stdout,
63 stderr,
64 } = options;
65 run_prepared(&mut command, mode, stdin, stdout, stderr)
66 }
67}
68
69#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
74fn run_prepared(
75 command: &mut ProcessCommand,
76 mode: CommandMode,
77 stdin: CommandStdin,
78 stdout: CommandStdout,
79 stderr: CommandStderr,
80) -> Result<CommandResult<ChildHandle>> {
81 let (stdout_stream, capture_buf) = match stdout {
82 CommandStdout::Inherit => (None, None),
83 CommandStdout::Stream(stream) => (Some(stream), None),
84 CommandStdout::Capture => {
85 if matches!(mode, CommandMode::Background) {
86 bail!("cannot capture stdout for background command");
87 }
88 let buf = Arc::new(Mutex::new(Vec::new()));
89 let writer: SharedOutput = buf.clone();
90 (Some(writer), Some(buf))
91 }
92 #[cfg(not(miri))]
93 CommandStdout::OsPipe(writer) => {
94 let owned = writer.take()?;
98 command.stdout(Stdio::from(owned));
99 (None, None)
100 }
101 };
102
103 let stderr_stream = match stderr {
104 CommandStderr::Inherit => None,
105 CommandStderr::Stream(stream) => Some(stream),
106 #[cfg(not(miri))]
107 CommandStderr::OsPipe(writer) => {
108 let owned = writer.take()?;
110 command.stderr(Stdio::from(owned));
111 None
112 }
113 };
114
115 let stdin_stream: Option<SharedInput> = match stdin {
116 CommandStdin::Null => {
118 command.stdin(Stdio::null());
119 None
120 }
121 CommandStdin::Inherit => None,
122 CommandStdin::Stream(reader) => Some(reader),
123 #[cfg(not(miri))]
124 CommandStdin::OsPipe(reader) => {
125 let owned = reader.take()?;
127 command.stdin(Stdio::from(owned));
128 None
129 }
130 };
131 let desc = format!("{:?}", command);
132
133 match mode {
134 CommandMode::Foreground => {
135 let mut handle =
136 spawn_child_with_streams(command, stdin_stream, stdout_stream, stderr_stream)?;
137 let status = handle
138 .wait()
139 .with_context(|| format!("failed to run {desc}"))?;
140 if !status.success() {
141 bail!("command {desc} failed with status {}", status);
142 }
143 if let Some(buf) = capture_buf {
144 let mut guard = buf.lock().map_err(|_| anyhow!("capture stdout poisoned"))?;
145 return Ok(CommandResult::Captured(std::mem::take(&mut *guard)));
146 }
147 Ok(CommandResult::Completed)
148 }
149 CommandMode::Background => {
150 let handle =
151 spawn_child_with_streams(command, stdin_stream, stdout_stream, stderr_stream)?;
152 Ok(CommandResult::Background(handle))
153 }
154 }
155}
156
157#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
158fn apply_ctx(command: &mut ProcessCommand, ctx: &CommandContext) {
159 let cwd_path: std::borrow::Cow<std::path::Path> = match ctx.cwd() {
175 PolicyPath::Guarded(p) => oxdock_fs::command_path(p),
176 PolicyPath::Unguarded(p) => std::borrow::Cow::Borrowed(p.as_path()),
177 };
178 command.current_dir(cwd_path);
179 command.envs(ctx.envs().as_ref());
180 if let Some(val) = ctx.envs().get("CARGO_TARGET_DIR") {
181 command.env("CARGO_TARGET_DIR", val);
182 } else {
183 command.env(
184 "CARGO_TARGET_DIR",
185 oxdock_fs::command_path(ctx.cargo_target_dir()).into_owned(),
186 );
187 }
188}
189
190#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
191fn spawn_child_with_streams(
192 cmd: &mut ProcessCommand,
193 stdin: Option<SharedInput>,
194 stdout: Option<SharedOutput>,
195 stderr: Option<SharedOutput>,
196) -> Result<ChildHandle> {
197 if stdin.is_some() {
198 cmd.stdin(Stdio::piped());
199 }
200 if stdout.is_some() {
201 cmd.stdout(Stdio::piped());
202 }
203 if stderr.is_some() {
204 cmd.stderr(Stdio::piped());
205 }
206
207 let mut child = cmd
208 .spawn()
209 .with_context(|| format!("failed to spawn {:?}", cmd))?;
210 let mut io_threads = Vec::new();
211
212 if let Some(stdin_stream) = stdin
213 && let Some(mut child_stdin) = child.stdin.take()
214 {
215 let thread = std::thread::spawn(move || {
216 if let Ok(mut guard) = stdin_stream.lock() {
217 let _ = std::io::copy(&mut *guard, &mut child_stdin);
218 }
219 });
220 io_threads.push(thread);
221 }
222
223 if let Some(stdout_stream) = stdout
224 && let Some(mut child_stdout) = child.stdout.take()
225 {
226 let stream_clone = stdout_stream.clone();
227 let thread = std::thread::spawn(move || {
228 let mut buf = [0u8; 1024];
229 loop {
230 match std::io::Read::read(&mut child_stdout, &mut buf) {
231 Ok(0) => break,
232 Ok(n) => {
233 if let Ok(mut guard) = stream_clone.lock() {
234 if std::io::Write::write_all(&mut *guard, &buf[..n]).is_err() {
235 break;
236 }
237 let _ = std::io::Write::flush(&mut *guard);
238 }
239 }
240 Err(_) => break,
241 }
242 }
243 });
244 io_threads.push(thread);
245 }
246
247 if let Some(stderr_stream) = stderr
248 && let Some(mut child_stderr) = child.stderr.take()
249 {
250 let stream_clone = stderr_stream.clone();
251 let thread = std::thread::spawn(move || {
252 let mut buf = [0u8; 1024];
253 loop {
254 match std::io::Read::read(&mut child_stderr, &mut buf) {
255 Ok(0) => break,
256 Ok(n) => {
257 if let Ok(mut guard) = stream_clone.lock() {
258 if std::io::Write::write_all(&mut *guard, &buf[..n]).is_err() {
259 break;
260 }
261 let _ = std::io::Write::flush(&mut *guard);
262 }
263 }
264 Err(_) => break,
265 }
266 }
267 });
268 io_threads.push(thread);
269 }
270
271 Ok(ChildHandle::new(child, io_threads))
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use oxdock_fs::GuardedPath;
278 use std::collections::HashMap;
279
280 fn make_ctx(envs: &[(&str, &str)]) -> (oxdock_fs::GuardedTempDir, CommandContext) {
281 let temp = GuardedPath::tempdir().expect("tempdir");
282 let guard = temp.as_guarded_path().clone();
283 let cwd: PolicyPath = guard.clone().into();
284 let map: HashMap<String, String> = envs
285 .iter()
286 .map(|(key, value)| (key.to_string(), value.to_string()))
287 .collect();
288 let ctx = CommandContext::from_map(&cwd, &map, &guard, &guard, &guard);
289 (temp, ctx)
290 }
291
292 #[test]
293 fn background_capture_stdout_bails_without_spawning() {
294 let (_temp, ctx) = make_ctx(&[]);
295 let mut pm = ShellProcessManager;
296 let options = CommandOptions {
297 mode: CommandMode::Background,
298 stdout: CommandStdout::Capture,
299 ..Default::default()
300 };
301 let err = match pm.run_command(&ctx, "echo hi", options) {
302 Err(err) => err,
303 Ok(_) => panic!("background capture must bail"),
304 };
305 assert!(
306 err.to_string().contains("cannot capture stdout"),
307 "unexpected error: {err}"
308 );
309 }
310
311 #[cfg_attr(
312 miri,
313 ignore = "spawns processes; Miri does not support process execution"
314 )]
315 #[test]
316 fn foreground_capture_returns_child_stdout_bytes() {
317 let (_temp, ctx) = make_ctx(&[]);
318 let mut pm = ShellProcessManager;
319 let options = CommandOptions {
320 stdout: CommandStdout::Capture,
321 ..Default::default()
322 };
323 match pm
324 .run_command(&ctx, "echo hello-capture", options)
325 .expect("run")
326 {
327 CommandResult::Captured(bytes) => {
328 let out = String::from_utf8_lossy(&bytes);
329 assert!(out.contains("hello-capture"), "captured: {out}");
330 }
331 CommandResult::Completed => panic!("expected Captured, got Completed"),
332 CommandResult::Background(_) => panic!("expected Captured, got Background"),
333 }
334 }
335
336 #[cfg_attr(
337 miri,
338 ignore = "spawns processes; Miri does not support process execution"
339 )]
340 #[test]
341 fn run_argv_spawns_directly_without_shell() {
342 let (_temp, ctx) = make_ctx(&[]);
343 let mut pm = ShellProcessManager;
344 let options = CommandOptions {
345 stdout: CommandStdout::Capture,
346 ..Default::default()
347 };
348 let argv = vec!["cargo".to_string(), "--version".to_string()];
352 match pm.run_argv(&ctx, &argv, options).expect("run_argv") {
353 CommandResult::Captured(bytes) => {
354 let out = String::from_utf8_lossy(&bytes);
355 assert!(out.contains("cargo"), "captured: {out}");
356 }
357 CommandResult::Completed => panic!("expected Captured, got Completed"),
358 CommandResult::Background(_) => panic!("expected Captured, got Background"),
359 }
360 }
361
362 #[test]
363 fn run_argv_rejects_empty_argv_without_spawning() {
364 let (_temp, ctx) = make_ctx(&[]);
365 let mut pm = ShellProcessManager;
366 let err = match pm.run_argv(&ctx, &[], CommandOptions::foreground()) {
367 Err(err) => err,
368 Ok(_) => panic!("empty argv must bail"),
369 };
370 assert!(
371 err.to_string().contains("at least one argument"),
372 "unexpected error: {err}"
373 );
374 }
375
376 fn large_output_script() -> &'static str {
377 #[cfg(windows)]
378 {
379 "for /l %i in (1,1,20000) do @echo 0123456789abcdef"
380 }
381 #[cfg(not(windows))]
382 {
383 "i=0; while [ $i -lt 20000 ]; do echo 0123456789abcdef; i=$((i+1)); done"
384 }
385 }
386
387 #[cfg_attr(
388 miri,
389 ignore = "spawns processes; Miri does not support process execution"
390 )]
391 #[test]
392 fn streams_large_stdout_through_shared_output_without_deadlock() {
393 let (_temp, ctx) = make_ctx(&[]);
394 let mut pm = ShellProcessManager;
395 let buffer = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
396 let options = CommandOptions {
397 stdout: CommandStdout::Stream(buffer.clone()),
398 ..Default::default()
399 };
400 pm.run_command(&ctx, large_output_script(), options)
401 .expect("run");
402 let bytes = buffer.lock().expect("buffer lock").len();
403 assert!(bytes >= 300_000, "streamed only {bytes} bytes");
406 }
407
408 #[cfg_attr(
409 miri,
410 ignore = "spawns processes; Miri does not support process execution"
411 )]
412 #[test]
413 fn foreground_stdin_is_piped_through_copy_thread() {
414 let (_temp, ctx) = make_ctx(&[]);
415 let mut pm = ShellProcessManager;
416 #[cfg(windows)]
418 let input: &[u8] = b"b\r\na\r\n";
419 #[cfg(not(windows))]
420 let input: &[u8] = b"b\na\n";
421 let payload: SharedInput =
422 std::sync::Arc::new(std::sync::Mutex::new(std::io::Cursor::new(input.to_vec())));
423 let options = CommandOptions {
424 stdin: CommandStdin::Stream(payload),
425 stdout: CommandStdout::Capture,
426 ..Default::default()
427 };
428 match pm.run_command(&ctx, "sort", options).expect("run") {
430 CommandResult::Captured(bytes) => {
431 assert!(bytes.starts_with(b"a"), "sorted output: {:?}", bytes);
432 assert!(windows_compatible_contains(&bytes, b"b"));
433 }
434 CommandResult::Completed => panic!("expected Captured, got Completed"),
435 CommandResult::Background(_) => panic!("expected Captured, got Background"),
436 }
437 }
438
439 fn windows_compatible_contains(haystack: &[u8], needle: &[u8]) -> bool {
440 haystack.windows(needle.len()).any(|w| w == needle)
441 }
442
443 #[cfg(not(miri))]
447 #[test]
448 fn os_pipe_streams_producer_to_consumer_with_eof() {
449 use crate::contract::{BackgroundHandle, create_os_pipe};
450
451 let (_temp, ctx) = make_ctx(&[]);
452 let (reader, writer) = create_os_pipe().expect("os pipe");
453 let mut pm = ShellProcessManager;
454
455 let producer = match pm
456 .run_command(
457 &ctx,
458 "echo hello-os-pipe",
459 CommandOptions {
460 mode: CommandMode::Background,
461 stdout: CommandStdout::OsPipe(writer),
462 ..Default::default()
463 },
464 )
465 .expect("spawn producer")
466 {
467 CommandResult::Background(handle) => handle,
468 _ => panic!("expected background producer handle"),
469 };
470
471 let options = CommandOptions {
472 stdin: CommandStdin::OsPipe(reader),
473 stdout: CommandStdout::Capture,
474 ..Default::default()
475 };
476 let captured = match pm.run_command(&ctx, "sort", options).expect("run consumer") {
478 CommandResult::Captured(bytes) => bytes,
479 _ => panic!("expected captured consumer output"),
480 };
481 assert!(
482 windows_compatible_contains(&captured, b"hello-os-pipe"),
483 "piped output: {:?}",
484 String::from_utf8_lossy(&captured)
485 );
486
487 let mut producer = producer;
488 let status = producer.wait().expect("wait producer");
489 assert!(status.success(), "producer failed: {status:?}");
490
491 let (spent_reader, spent_writer) = create_os_pipe().expect("os pipe");
493 spent_reader.take().expect("first reader take");
494 assert!(
495 spent_reader.take().is_err(),
496 "reader take must be single use"
497 );
498 spent_writer.take().expect("first writer take");
499 assert!(
500 spent_writer.take().is_err(),
501 "writer take must be single use"
502 );
503 }
504
505 #[test]
506 #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
507 fn apply_ctx_sets_cwd_and_cargo_target_dir_precedence() {
508 let (temp_a, ctx_a) = make_ctx(&[]);
511 let expected_default = oxdock_fs::command_path(ctx_a.cargo_target_dir())
512 .to_string_lossy()
513 .into_owned();
514 let mut cmd = ProcessCommand::new("prog");
515 apply_ctx(&mut cmd, &ctx_a);
516 let envs_a: HashMap<String, String> = cmd
517 .get_envs()
518 .map(|(k, v)| {
519 (
520 k.to_string_lossy().into_owned(),
521 v.map(|value| value.to_string_lossy().into_owned())
522 .unwrap_or_default(),
523 )
524 })
525 .collect();
526 assert_eq!(envs_a.get("CARGO_TARGET_DIR"), Some(&expected_default));
527 drop(temp_a);
528
529 let (temp_b, ctx_b) = make_ctx(&[("CARGO_TARGET_DIR", "custom-target"), ("FOO", "bar")]);
532 let mut cmd = ProcessCommand::new("prog");
533 apply_ctx(&mut cmd, &ctx_b);
534 let envs_b: HashMap<String, String> = cmd
535 .get_envs()
536 .map(|(k, v)| {
537 (
538 k.to_string_lossy().into_owned(),
539 v.map(|value| value.to_string_lossy().into_owned())
540 .unwrap_or_default(),
541 )
542 })
543 .collect();
544 assert_eq!(
545 envs_b.get("CARGO_TARGET_DIR").map(String::as_str),
546 Some("custom-target")
547 );
548 assert_eq!(envs_b.get("FOO").map(String::as_str), Some("bar"));
549 drop(temp_b);
550 }
551
552 #[test]
553 #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
554 fn apply_ctx_sets_working_directory_from_guarded_cwd() {
555 let (_temp, ctx) = make_ctx(&[]);
556 let mut cmd = ProcessCommand::new("prog");
557 apply_ctx(&mut cmd, &ctx);
558 let expected = oxdock_fs::command_path(match ctx.cwd() {
559 PolicyPath::Guarded(guarded) => guarded,
560 PolicyPath::Unguarded(_) => panic!("expected guarded cwd"),
561 });
562 assert_eq!(cmd.get_current_dir(), Some(expected.as_ref()));
563 }
564}