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 let mut stdin_thread = None;
212
213 if let Some(stdin_stream) = stdin
214 && let Some(mut child_stdin) = child.stdin.take()
215 {
216 let thread = std::thread::spawn(move || {
217 if let Ok(mut guard) = stdin_stream.lock() {
218 let _ = std::io::copy(&mut *guard, &mut child_stdin);
219 }
220 });
221 stdin_thread = Some(thread);
222 }
223
224 if let Some(stdout_stream) = stdout
225 && let Some(mut child_stdout) = child.stdout.take()
226 {
227 let stream_clone = stdout_stream.clone();
228 let thread = std::thread::spawn(move || {
229 let mut buf = [0u8; 1024];
230 loop {
231 match std::io::Read::read(&mut child_stdout, &mut buf) {
232 Ok(0) => break,
233 Ok(n) => {
234 if let Ok(mut guard) = stream_clone.lock() {
235 if std::io::Write::write_all(&mut *guard, &buf[..n]).is_err() {
236 break;
237 }
238 let _ = std::io::Write::flush(&mut *guard);
239 }
240 }
241 Err(_) => break,
242 }
243 }
244 });
245 io_threads.push(thread);
246 }
247
248 if let Some(stderr_stream) = stderr
249 && let Some(mut child_stderr) = child.stderr.take()
250 {
251 let stream_clone = stderr_stream.clone();
252 let thread = std::thread::spawn(move || {
253 let mut buf = [0u8; 1024];
254 loop {
255 match std::io::Read::read(&mut child_stderr, &mut buf) {
256 Ok(0) => break,
257 Ok(n) => {
258 if let Ok(mut guard) = stream_clone.lock() {
259 if std::io::Write::write_all(&mut *guard, &buf[..n]).is_err() {
260 break;
261 }
262 let _ = std::io::Write::flush(&mut *guard);
263 }
264 }
265 Err(_) => break,
266 }
267 }
268 });
269 io_threads.push(thread);
270 }
271
272 Ok(ChildHandle::new(child, stdin_thread, io_threads))
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use oxdock_fs::GuardedPath;
279 use std::collections::HashMap;
280
281 fn make_ctx(envs: &[(&str, &str)]) -> (oxdock_fs::GuardedTempDir, CommandContext) {
282 let temp = GuardedPath::tempdir().expect("tempdir");
283 let guard = temp.as_guarded_path().clone();
284 let cwd: PolicyPath = guard.clone().into();
285 let map: HashMap<String, String> = envs
286 .iter()
287 .map(|(key, value)| (key.to_string(), value.to_string()))
288 .collect();
289 let scratch = oxdock_fs::reserve_cargo_scratch().expect("scratch");
290 let ctx = CommandContext::from_map(&cwd, &map, &scratch, &guard, &guard);
291 (temp, ctx)
292 }
293
294 #[test]
295 fn background_capture_stdout_bails_without_spawning() {
296 let (_temp, ctx) = make_ctx(&[]);
297 let mut pm = ShellProcessManager;
298 let options = CommandOptions {
299 mode: CommandMode::Background,
300 stdout: CommandStdout::Capture,
301 ..Default::default()
302 };
303 let err = match pm.run_command(&ctx, "echo hi", options) {
304 Err(err) => err,
305 Ok(_) => panic!("background capture must bail"),
306 };
307 assert!(
308 err.to_string().contains("cannot capture stdout"),
309 "unexpected error: {err}"
310 );
311 }
312
313 #[cfg_attr(
314 miri,
315 ignore = "spawns processes; Miri does not support process execution"
316 )]
317 #[test]
318 fn foreground_capture_returns_child_stdout_bytes() {
319 let (_temp, ctx) = make_ctx(&[]);
320 let mut pm = ShellProcessManager;
321 let options = CommandOptions {
322 stdout: CommandStdout::Capture,
323 ..Default::default()
324 };
325 match pm
326 .run_command(&ctx, "echo hello-capture", options)
327 .expect("run")
328 {
329 CommandResult::Captured(bytes) => {
330 let out = String::from_utf8_lossy(&bytes);
331 assert!(out.contains("hello-capture"), "captured: {out}");
332 }
333 CommandResult::Completed => panic!("expected Captured, got Completed"),
334 CommandResult::Background(_) => panic!("expected Captured, got Background"),
335 }
336 }
337
338 #[cfg_attr(
339 miri,
340 ignore = "spawns processes; Miri does not support process execution"
341 )]
342 #[test]
343 fn run_argv_spawns_directly_without_shell() {
344 let (_temp, ctx) = make_ctx(&[]);
345 let mut pm = ShellProcessManager;
346 let options = CommandOptions {
347 stdout: CommandStdout::Capture,
348 ..Default::default()
349 };
350 let argv = vec!["cargo".to_string(), "--version".to_string()];
354 match pm.run_argv(&ctx, &argv, options).expect("run_argv") {
355 CommandResult::Captured(bytes) => {
356 let out = String::from_utf8_lossy(&bytes);
357 assert!(out.contains("cargo"), "captured: {out}");
358 }
359 CommandResult::Completed => panic!("expected Captured, got Completed"),
360 CommandResult::Background(_) => panic!("expected Captured, got Background"),
361 }
362 }
363
364 #[test]
365 fn run_argv_rejects_empty_argv_without_spawning() {
366 let (_temp, ctx) = make_ctx(&[]);
367 let mut pm = ShellProcessManager;
368 let err = match pm.run_argv(&ctx, &[], CommandOptions::foreground()) {
369 Err(err) => err,
370 Ok(_) => panic!("empty argv must bail"),
371 };
372 assert!(
373 err.to_string().contains("at least one argument"),
374 "unexpected error: {err}"
375 );
376 }
377
378 fn large_output_script() -> &'static str {
379 #[cfg(windows)]
380 {
381 "for /l %i in (1,1,20000) do @echo 0123456789abcdef"
382 }
383 #[cfg(not(windows))]
384 {
385 "i=0; while [ $i -lt 20000 ]; do echo 0123456789abcdef; i=$((i+1)); done"
386 }
387 }
388
389 #[cfg_attr(
390 miri,
391 ignore = "spawns processes; Miri does not support process execution"
392 )]
393 #[test]
394 fn streams_large_stdout_through_shared_output_without_deadlock() {
395 let (_temp, ctx) = make_ctx(&[]);
396 let mut pm = ShellProcessManager;
397 let buffer = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
398 let options = CommandOptions {
399 stdout: CommandStdout::Stream(buffer.clone()),
400 ..Default::default()
401 };
402 pm.run_command(&ctx, large_output_script(), options)
403 .expect("run");
404 let bytes = buffer.lock().expect("buffer lock").len();
405 assert!(bytes >= 300_000, "streamed only {bytes} bytes");
408 }
409
410 #[cfg_attr(
411 miri,
412 ignore = "spawns processes; Miri does not support process execution"
413 )]
414 #[test]
415 fn foreground_stdin_is_piped_through_copy_thread() {
416 let (_temp, ctx) = make_ctx(&[]);
417 let mut pm = ShellProcessManager;
418 #[cfg(windows)]
420 let input: &[u8] = b"b\r\na\r\n";
421 #[cfg(not(windows))]
422 let input: &[u8] = b"b\na\n";
423 let payload: SharedInput =
424 std::sync::Arc::new(std::sync::Mutex::new(std::io::Cursor::new(input.to_vec())));
425 let options = CommandOptions {
426 stdin: CommandStdin::Stream(payload),
427 stdout: CommandStdout::Capture,
428 ..Default::default()
429 };
430 match pm.run_command(&ctx, "sort", options).expect("run") {
432 CommandResult::Captured(bytes) => {
433 assert!(bytes.starts_with(b"a"), "sorted output: {:?}", bytes);
434 assert!(windows_compatible_contains(&bytes, b"b"));
435 }
436 CommandResult::Completed => panic!("expected Captured, got Completed"),
437 CommandResult::Background(_) => panic!("expected Captured, got Background"),
438 }
439 }
440
441 fn windows_compatible_contains(haystack: &[u8], needle: &[u8]) -> bool {
442 haystack.windows(needle.len()).any(|w| w == needle)
443 }
444
445 #[cfg(not(miri))]
449 #[test]
450 fn os_pipe_streams_producer_to_consumer_with_eof() {
451 use crate::contract::{BackgroundHandle, create_os_pipe};
452
453 let (_temp, ctx) = make_ctx(&[]);
454 let (reader, writer) = create_os_pipe().expect("os pipe");
455 let mut pm = ShellProcessManager;
456
457 let producer = match pm
458 .run_command(
459 &ctx,
460 "echo hello-os-pipe",
461 CommandOptions {
462 mode: CommandMode::Background,
463 stdout: CommandStdout::OsPipe(writer),
464 ..Default::default()
465 },
466 )
467 .expect("spawn producer")
468 {
469 CommandResult::Background(handle) => handle,
470 _ => panic!("expected background producer handle"),
471 };
472
473 let options = CommandOptions {
474 stdin: CommandStdin::OsPipe(reader),
475 stdout: CommandStdout::Capture,
476 ..Default::default()
477 };
478 let captured = match pm.run_command(&ctx, "sort", options).expect("run consumer") {
480 CommandResult::Captured(bytes) => bytes,
481 _ => panic!("expected captured consumer output"),
482 };
483 assert!(
484 windows_compatible_contains(&captured, b"hello-os-pipe"),
485 "piped output: {:?}",
486 String::from_utf8_lossy(&captured)
487 );
488
489 let mut producer = producer;
490 let status = producer.wait().expect("wait producer");
491 assert!(status.success(), "producer failed: {status:?}");
492
493 let (spent_reader, spent_writer) = create_os_pipe().expect("os pipe");
495 spent_reader.take().expect("first reader take");
496 assert!(
497 spent_reader.take().is_err(),
498 "reader take must be single use"
499 );
500 spent_writer.take().expect("first writer take");
501 assert!(
502 spent_writer.take().is_err(),
503 "writer take must be single use"
504 );
505 }
506
507 #[test]
508 #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
509 fn apply_ctx_sets_cwd_and_cargo_target_dir_precedence() {
510 let (temp_a, ctx_a) = make_ctx(&[]);
513 let expected_default = ctx_a
514 .cargo_target_dir()
515 .command_path()
516 .to_string_lossy()
517 .into_owned();
518 let mut cmd = ProcessCommand::new("prog");
519 apply_ctx(&mut cmd, &ctx_a);
520 let envs_a: HashMap<String, String> = cmd
521 .get_envs()
522 .map(|(k, v)| {
523 (
524 k.to_string_lossy().into_owned(),
525 v.map(|value| value.to_string_lossy().into_owned())
526 .unwrap_or_default(),
527 )
528 })
529 .collect();
530 assert_eq!(envs_a.get("CARGO_TARGET_DIR"), Some(&expected_default));
531 drop(temp_a);
532
533 let (temp_b, ctx_b) = make_ctx(&[("CARGO_TARGET_DIR", "custom-target"), ("FOO", "bar")]);
536 let mut cmd = ProcessCommand::new("prog");
537 apply_ctx(&mut cmd, &ctx_b);
538 let envs_b: HashMap<String, String> = cmd
539 .get_envs()
540 .map(|(k, v)| {
541 (
542 k.to_string_lossy().into_owned(),
543 v.map(|value| value.to_string_lossy().into_owned())
544 .unwrap_or_default(),
545 )
546 })
547 .collect();
548 assert_eq!(
549 envs_b.get("CARGO_TARGET_DIR").map(String::as_str),
550 Some("custom-target")
551 );
552 assert_eq!(envs_b.get("FOO").map(String::as_str), Some("bar"));
553 drop(temp_b);
554 }
555
556 #[test]
557 #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
558 fn apply_ctx_sets_working_directory_from_guarded_cwd() {
559 let (_temp, ctx) = make_ctx(&[]);
560 let mut cmd = ProcessCommand::new("prog");
561 apply_ctx(&mut cmd, &ctx);
562 let expected = oxdock_fs::command_path(match ctx.cwd() {
563 PolicyPath::Guarded(guarded) => guarded,
564 PolicyPath::Unguarded(_) => panic!("expected guarded cwd"),
565 });
566 assert_eq!(cmd.get_current_dir(), Some(expected.as_ref()));
567 }
568}