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 ctx.cargo_target_dir().command_path().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 scratch = oxdock_fs::reserve_cargo_scratch().expect("scratch");
289 let ctx = CommandContext::from_map(&cwd, &map, &scratch, &guard, &guard);
290 (temp, ctx)
291 }
292
293 #[test]
294 fn background_capture_stdout_bails_without_spawning() {
295 let (_temp, ctx) = make_ctx(&[]);
296 let mut pm = ShellProcessManager;
297 let options = CommandOptions {
298 mode: CommandMode::Background,
299 stdout: CommandStdout::Capture,
300 ..Default::default()
301 };
302 let err = match pm.run_command(&ctx, "echo hi", options) {
303 Err(err) => err,
304 Ok(_) => panic!("background capture must bail"),
305 };
306 assert!(
307 err.to_string().contains("cannot capture stdout"),
308 "unexpected error: {err}"
309 );
310 }
311
312 #[cfg_attr(
313 miri,
314 ignore = "spawns processes; Miri does not support process execution"
315 )]
316 #[test]
317 fn foreground_capture_returns_child_stdout_bytes() {
318 let (_temp, ctx) = make_ctx(&[]);
319 let mut pm = ShellProcessManager;
320 let options = CommandOptions {
321 stdout: CommandStdout::Capture,
322 ..Default::default()
323 };
324 match pm
325 .run_command(&ctx, "echo hello-capture", options)
326 .expect("run")
327 {
328 CommandResult::Captured(bytes) => {
329 let out = String::from_utf8_lossy(&bytes);
330 assert!(out.contains("hello-capture"), "captured: {out}");
331 }
332 CommandResult::Completed => panic!("expected Captured, got Completed"),
333 CommandResult::Background(_) => panic!("expected Captured, got Background"),
334 }
335 }
336
337 #[cfg_attr(
338 miri,
339 ignore = "spawns processes; Miri does not support process execution"
340 )]
341 #[test]
342 fn run_argv_spawns_directly_without_shell() {
343 let (_temp, ctx) = make_ctx(&[]);
344 let mut pm = ShellProcessManager;
345 let options = CommandOptions {
346 stdout: CommandStdout::Capture,
347 ..Default::default()
348 };
349 let argv = vec!["cargo".to_string(), "--version".to_string()];
353 match pm.run_argv(&ctx, &argv, options).expect("run_argv") {
354 CommandResult::Captured(bytes) => {
355 let out = String::from_utf8_lossy(&bytes);
356 assert!(out.contains("cargo"), "captured: {out}");
357 }
358 CommandResult::Completed => panic!("expected Captured, got Completed"),
359 CommandResult::Background(_) => panic!("expected Captured, got Background"),
360 }
361 }
362
363 #[test]
364 fn run_argv_rejects_empty_argv_without_spawning() {
365 let (_temp, ctx) = make_ctx(&[]);
366 let mut pm = ShellProcessManager;
367 let err = match pm.run_argv(&ctx, &[], CommandOptions::foreground()) {
368 Err(err) => err,
369 Ok(_) => panic!("empty argv must bail"),
370 };
371 assert!(
372 err.to_string().contains("at least one argument"),
373 "unexpected error: {err}"
374 );
375 }
376
377 fn large_output_script() -> &'static str {
378 #[cfg(windows)]
379 {
380 "for /l %i in (1,1,20000) do @echo 0123456789abcdef"
381 }
382 #[cfg(not(windows))]
383 {
384 "i=0; while [ $i -lt 20000 ]; do echo 0123456789abcdef; i=$((i+1)); done"
385 }
386 }
387
388 #[cfg_attr(
389 miri,
390 ignore = "spawns processes; Miri does not support process execution"
391 )]
392 #[test]
393 fn streams_large_stdout_through_shared_output_without_deadlock() {
394 let (_temp, ctx) = make_ctx(&[]);
395 let mut pm = ShellProcessManager;
396 let buffer = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
397 let options = CommandOptions {
398 stdout: CommandStdout::Stream(buffer.clone()),
399 ..Default::default()
400 };
401 pm.run_command(&ctx, large_output_script(), options)
402 .expect("run");
403 let bytes = buffer.lock().expect("buffer lock").len();
404 assert!(bytes >= 300_000, "streamed only {bytes} bytes");
407 }
408
409 #[cfg_attr(
410 miri,
411 ignore = "spawns processes; Miri does not support process execution"
412 )]
413 #[test]
414 fn foreground_stdin_is_piped_through_copy_thread() {
415 let (_temp, ctx) = make_ctx(&[]);
416 let mut pm = ShellProcessManager;
417 #[cfg(windows)]
419 let input: &[u8] = b"b\r\na\r\n";
420 #[cfg(not(windows))]
421 let input: &[u8] = b"b\na\n";
422 let payload: SharedInput =
423 std::sync::Arc::new(std::sync::Mutex::new(std::io::Cursor::new(input.to_vec())));
424 let options = CommandOptions {
425 stdin: CommandStdin::Stream(payload),
426 stdout: CommandStdout::Capture,
427 ..Default::default()
428 };
429 match pm.run_command(&ctx, "sort", options).expect("run") {
431 CommandResult::Captured(bytes) => {
432 assert!(bytes.starts_with(b"a"), "sorted output: {:?}", bytes);
433 assert!(windows_compatible_contains(&bytes, b"b"));
434 }
435 CommandResult::Completed => panic!("expected Captured, got Completed"),
436 CommandResult::Background(_) => panic!("expected Captured, got Background"),
437 }
438 }
439
440 fn windows_compatible_contains(haystack: &[u8], needle: &[u8]) -> bool {
441 haystack.windows(needle.len()).any(|w| w == needle)
442 }
443
444 #[cfg(not(miri))]
448 #[test]
449 fn os_pipe_streams_producer_to_consumer_with_eof() {
450 use crate::contract::{BackgroundHandle, create_os_pipe};
451
452 let (_temp, ctx) = make_ctx(&[]);
453 let (reader, writer) = create_os_pipe().expect("os pipe");
454 let mut pm = ShellProcessManager;
455
456 let producer = match pm
457 .run_command(
458 &ctx,
459 "echo hello-os-pipe",
460 CommandOptions {
461 mode: CommandMode::Background,
462 stdout: CommandStdout::OsPipe(writer),
463 ..Default::default()
464 },
465 )
466 .expect("spawn producer")
467 {
468 CommandResult::Background(handle) => handle,
469 _ => panic!("expected background producer handle"),
470 };
471
472 let options = CommandOptions {
473 stdin: CommandStdin::OsPipe(reader),
474 stdout: CommandStdout::Capture,
475 ..Default::default()
476 };
477 let captured = match pm.run_command(&ctx, "sort", options).expect("run consumer") {
479 CommandResult::Captured(bytes) => bytes,
480 _ => panic!("expected captured consumer output"),
481 };
482 assert!(
483 windows_compatible_contains(&captured, b"hello-os-pipe"),
484 "piped output: {:?}",
485 String::from_utf8_lossy(&captured)
486 );
487
488 let mut producer = producer;
489 let status = producer.wait().expect("wait producer");
490 assert!(status.success(), "producer failed: {status:?}");
491
492 let (spent_reader, spent_writer) = create_os_pipe().expect("os pipe");
494 spent_reader.take().expect("first reader take");
495 assert!(
496 spent_reader.take().is_err(),
497 "reader take must be single use"
498 );
499 spent_writer.take().expect("first writer take");
500 assert!(
501 spent_writer.take().is_err(),
502 "writer take must be single use"
503 );
504 }
505
506 #[test]
507 #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
508 fn apply_ctx_sets_cwd_and_cargo_target_dir_precedence() {
509 let (temp_a, ctx_a) = make_ctx(&[]);
512 let expected_default = ctx_a
513 .cargo_target_dir()
514 .command_path()
515 .to_string_lossy()
516 .into_owned();
517 let mut cmd = ProcessCommand::new("prog");
518 apply_ctx(&mut cmd, &ctx_a);
519 let envs_a: HashMap<String, String> = cmd
520 .get_envs()
521 .map(|(k, v)| {
522 (
523 k.to_string_lossy().into_owned(),
524 v.map(|value| value.to_string_lossy().into_owned())
525 .unwrap_or_default(),
526 )
527 })
528 .collect();
529 assert_eq!(envs_a.get("CARGO_TARGET_DIR"), Some(&expected_default));
530 drop(temp_a);
531
532 let (temp_b, ctx_b) = make_ctx(&[("CARGO_TARGET_DIR", "custom-target"), ("FOO", "bar")]);
535 let mut cmd = ProcessCommand::new("prog");
536 apply_ctx(&mut cmd, &ctx_b);
537 let envs_b: HashMap<String, String> = cmd
538 .get_envs()
539 .map(|(k, v)| {
540 (
541 k.to_string_lossy().into_owned(),
542 v.map(|value| value.to_string_lossy().into_owned())
543 .unwrap_or_default(),
544 )
545 })
546 .collect();
547 assert_eq!(
548 envs_b.get("CARGO_TARGET_DIR").map(String::as_str),
549 Some("custom-target")
550 );
551 assert_eq!(envs_b.get("FOO").map(String::as_str), Some("bar"));
552 drop(temp_b);
553 }
554
555 #[test]
556 #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
557 fn apply_ctx_sets_working_directory_from_guarded_cwd() {
558 let (_temp, ctx) = make_ctx(&[]);
559 let mut cmd = ProcessCommand::new("prog");
560 apply_ctx(&mut cmd, &ctx);
561 let expected = oxdock_fs::command_path(match ctx.cwd() {
562 PolicyPath::Guarded(guarded) => guarded,
563 PolicyPath::Unguarded(_) => panic!("expected guarded cwd"),
564 });
565 assert_eq!(cmd.get_current_dir(), Some(expected.as_ref()));
566 }
567}