1pub mod builtin_env;
2#[cfg(feature = "mock-process")]
3mod mock;
4pub mod serial_cargo_env;
5mod shell;
6
7use anyhow::{Context, Result, anyhow, bail};
8pub use builtin_env::BuiltinEnv;
9use oxdock_fs::{GuardedPath, PolicyPath};
10pub use oxdock_sys_test_utils::TestEnvGuard;
11use shell::shell_cmd;
12pub use shell::{ShellLauncher, shell_program};
13use std::collections::HashMap;
14#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
15use std::fs::File;
16#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
17use std::path::{Path, PathBuf};
18#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
19use std::process::{Child, Command as ProcessCommand, ExitStatus, Output as StdOutput, Stdio};
20use std::{
21 ffi::{OsStr, OsString},
22 iter::IntoIterator,
23 mem,
24};
25
26#[cfg(miri)]
27use oxdock_fs::PathResolver;
28
29#[cfg(feature = "mock-process")]
30pub use mock::{MockHandle, MockProcessManager, MockRunCall, MockSpawnCall};
31
32#[derive(Clone, Debug)]
36pub struct CommandContext {
37 cwd: PolicyPath,
38 envs: HashMap<String, String>,
39 cargo_target_dir: GuardedPath,
40 workspace_root: GuardedPath,
41 build_context: GuardedPath,
42}
43
44impl CommandContext {
45 #[allow(clippy::too_many_arguments)]
46 pub fn new(
47 cwd: &PolicyPath,
48 envs: &HashMap<String, String>,
49 cargo_target_dir: &GuardedPath,
50 workspace_root: &GuardedPath,
51 build_context: &GuardedPath,
52 ) -> Self {
53 Self {
54 cwd: cwd.clone(),
55 envs: envs.clone(),
56 cargo_target_dir: cargo_target_dir.clone(),
57 workspace_root: workspace_root.clone(),
58 build_context: build_context.clone(),
59 }
60 }
61
62 pub fn cwd(&self) -> &PolicyPath {
63 &self.cwd
64 }
65
66 pub fn envs(&self) -> &HashMap<String, String> {
67 &self.envs
68 }
69
70 pub fn cargo_target_dir(&self) -> &GuardedPath {
71 &self.cargo_target_dir
72 }
73
74 pub fn workspace_root(&self) -> &GuardedPath {
75 &self.workspace_root
76 }
77
78 pub fn build_context(&self) -> &GuardedPath {
79 &self.build_context
80 }
81}
82
83fn expand_with_lookup<F>(input: &str, mut lookup: F) -> String
84where
85 F: FnMut(&str) -> Option<String>,
86{
87 let mut out = String::with_capacity(input.len());
88 let mut chars = input.chars().peekable();
89 while let Some(c) = chars.next() {
90 if c == '{' {
91 if let Some(&'{') = chars.peek() {
92 chars.next(); let mut content = String::new();
94 let mut closed = false;
95 let mut inner_chars = chars.clone();
97 while let Some(ch) = inner_chars.next() {
98 if ch == '}'
99 && let Some(&'}') = inner_chars.peek()
100 {
101 closed = true;
102 break;
103 }
104 content.push(ch);
105 }
106
107 if closed {
108 for _ in 0..content.len() {
110 chars.next();
111 }
112 chars.next(); chars.next(); let key = content.trim();
116 if !key.is_empty() {
117 out.push_str(&lookup(key).unwrap_or_default());
118 }
119 } else {
120 out.push('{');
121 out.push('{');
122 }
123 } else {
124 out.push('{');
125 }
126 } else {
127 out.push(c);
128 }
129 }
130 out
131}
132
133pub fn expand_script_env(input: &str, script_envs: &HashMap<String, String>) -> String {
134 expand_with_lookup(input, |name| {
135 if let Some(key) = name.strip_prefix("env:") {
136 script_envs
137 .get(key)
138 .cloned()
139 .or_else(|| std::env::var(key).ok())
140 } else {
141 None
142 }
143 })
144}
145
146pub fn expand_command_env(input: &str, ctx: &CommandContext) -> String {
147 expand_with_lookup(input, |name| {
148 if let Some(key) = name.strip_prefix("env:") {
149 ctx.envs().get(key).cloned()
150 } else {
151 None
152 }
153 })
154}
155
156pub trait BackgroundHandle {
158 fn try_wait(&mut self) -> Result<Option<ExitStatus>>;
159 fn kill(&mut self) -> Result<()>;
160 fn wait(&mut self) -> Result<ExitStatus>;
161}
162
163use std::sync::{Arc, Mutex};
168
169pub type SharedInput = Arc<Mutex<dyn std::io::Read + Send>>;
170pub type SharedOutput = Arc<Mutex<dyn std::io::Write + Send>>;
171
172#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
173pub enum CommandMode {
174 #[default]
175 Foreground,
176 Background,
177}
178
179#[derive(Clone, Default)]
180pub enum CommandStdout {
181 #[default]
182 Inherit,
183 Stream(SharedOutput),
184 Capture,
185}
186
187#[derive(Clone, Default)]
188pub enum CommandStderr {
189 #[default]
190 Inherit,
191 Stream(SharedOutput),
192}
193
194#[derive(Clone, Default)]
195pub struct CommandOptions {
196 pub mode: CommandMode,
197 pub stdin: Option<SharedInput>,
198 pub stdout: CommandStdout,
199 pub stderr: CommandStderr,
200}
201
202impl CommandOptions {
203 pub fn foreground() -> Self {
204 Self::default()
205 }
206
207 pub fn background() -> Self {
208 Self {
209 mode: CommandMode::Background,
210 ..Self::default()
211 }
212 }
213}
214
215pub enum CommandResult<H> {
216 Completed,
217 Captured(Vec<u8>),
218 Background(H),
219}
220
221pub trait ProcessManager: Clone {
222 type Handle: BackgroundHandle;
223
224 fn run_command(
225 &mut self,
226 ctx: &CommandContext,
227 script: &str,
228 options: CommandOptions,
229 ) -> Result<CommandResult<Self::Handle>>;
230}
231
232#[derive(Clone, Default)]
234#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
235pub struct ShellProcessManager;
236
237impl ProcessManager for ShellProcessManager {
238 type Handle = ChildHandle;
239
240 #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
241 fn run_command(
242 &mut self,
243 ctx: &CommandContext,
244 script: &str,
245 options: CommandOptions,
246 ) -> Result<CommandResult<Self::Handle>> {
247 if std::env::var_os("OXBOOK_DEBUG").is_some() {
248 eprintln!("oxbook run_command: {script}");
249 }
250 let mut command = shell_cmd(script);
251 apply_ctx(&mut command, ctx);
252 let CommandOptions {
253 mode,
254 stdin,
255 stdout,
256 stderr,
257 } = options;
258
259 let (stdout_stream, capture_buf) = match stdout {
260 CommandStdout::Inherit => (None, None),
261 CommandStdout::Stream(stream) => (Some(stream), None),
262 CommandStdout::Capture => {
263 if matches!(mode, CommandMode::Background) {
264 bail!("cannot capture stdout for background command");
265 }
266 let buf = Arc::new(Mutex::new(Vec::new()));
267 let writer: SharedOutput = buf.clone();
268 (Some(writer), Some(buf))
269 }
270 };
271
272 let stderr_stream = match stderr {
273 CommandStderr::Inherit => None,
274 CommandStderr::Stream(stream) => Some(stream),
275 };
276
277 let need_null_stdin = stdin.is_none();
278 if need_null_stdin {
279 command.stdin(Stdio::null());
281 }
282 let desc = format!("{:?}", command);
283
284 match mode {
285 CommandMode::Foreground => {
286 let mut handle =
287 spawn_child_with_streams(&mut command, stdin, stdout_stream, stderr_stream)?;
288 let status = handle
289 .wait()
290 .with_context(|| format!("failed to run {desc}"))?;
291 if !status.success() {
292 bail!("command {desc} failed with status {}", status);
293 }
294 if let Some(buf) = capture_buf {
295 let mut guard = buf.lock().map_err(|_| anyhow!("capture stdout poisoned"))?;
296 return Ok(CommandResult::Captured(mem::take(&mut *guard)));
297 }
298 Ok(CommandResult::Completed)
299 }
300 CommandMode::Background => {
301 let handle =
302 spawn_child_with_streams(&mut command, stdin, stdout_stream, stderr_stream)?;
303 Ok(CommandResult::Background(handle))
304 }
305 }
306 }
307}
308
309#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
310fn apply_ctx(command: &mut ProcessCommand, ctx: &CommandContext) {
311 let cwd_path: std::borrow::Cow<std::path::Path> = match ctx.cwd() {
327 PolicyPath::Guarded(p) => oxdock_fs::command_path(p),
328 PolicyPath::Unguarded(p) => std::borrow::Cow::Borrowed(p.as_path()),
329 };
330 command.current_dir(cwd_path);
331 command.envs(ctx.envs());
332 if let Some(val) = ctx.envs().get("CARGO_TARGET_DIR") {
333 command.env("CARGO_TARGET_DIR", val);
334 } else {
335 command.env(
336 "CARGO_TARGET_DIR",
337 oxdock_fs::command_path(ctx.cargo_target_dir()).into_owned(),
338 );
339 }
340}
341
342#[derive(Debug)]
343#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
344pub struct ChildHandle {
345 child: Child,
346 io_threads: Vec<std::thread::JoinHandle<()>>,
347}
348
349impl BackgroundHandle for ChildHandle {
350 fn try_wait(&mut self) -> Result<Option<ExitStatus>> {
351 Ok(self.child.try_wait()?)
352 }
353
354 fn wait(&mut self) -> Result<ExitStatus> {
355 let status = self.child.wait()?;
356 for thread in self.io_threads.drain(..) {
358 let _ = thread.join();
359 }
360 Ok(status)
361 }
362
363 fn kill(&mut self) -> Result<()> {
364 if self.child.try_wait()?.is_none() {
365 let _ = self.child.kill();
366 }
367 Ok(())
368 }
369}
370
371#[cfg(miri)]
376#[derive(Clone, Default)]
377pub struct SyntheticProcessManager;
378
379#[cfg(miri)]
380#[derive(Clone)]
381pub struct SyntheticBgHandle {
382 ctx: CommandContext,
383 actions: Vec<Action>,
384 remaining: std::time::Duration,
385 last_polled: std::time::Instant,
386 status: ExitStatus,
387 applied: bool,
388 killed: bool,
389}
390
391#[cfg(miri)]
392#[derive(Clone)]
393enum Action {
394 WriteFile { target: GuardedPath, data: Vec<u8> },
395}
396
397#[cfg(miri)]
398impl BackgroundHandle for SyntheticBgHandle {
399 fn try_wait(&mut self) -> Result<Option<ExitStatus>> {
400 if self.killed {
401 self.applied = true;
402 return Ok(Some(self.status));
403 }
404 if self.applied {
405 return Ok(Some(self.status));
406 }
407 let now = std::time::Instant::now();
408 let elapsed = now.saturating_duration_since(self.last_polled);
409 const MAX_ADVANCE: std::time::Duration = std::time::Duration::from_millis(15);
410 let advance = elapsed.min(MAX_ADVANCE).min(self.remaining);
411 self.remaining = self.remaining.saturating_sub(advance);
412 self.last_polled = now;
413
414 if self.remaining.is_zero() {
415 apply_actions(&self.ctx, &self.actions)?;
416 self.applied = true;
417 Ok(Some(self.status))
418 } else {
419 Ok(None)
420 }
421 }
422
423 fn kill(&mut self) -> Result<()> {
424 self.killed = true;
425 Ok(())
426 }
427
428 fn wait(&mut self) -> Result<ExitStatus> {
429 if self.killed {
430 self.applied = true;
431 return Ok(self.status);
432 }
433 if !self.applied {
434 if !self.remaining.is_zero() {
435 std::thread::sleep(self.remaining);
436 }
437 apply_actions(&self.ctx, &self.actions)?;
438 self.applied = true;
439 }
440 Ok(self.status)
441 }
442}
443
444#[cfg(miri)]
445impl ProcessManager for SyntheticProcessManager {
446 type Handle = SyntheticBgHandle;
447
448 fn run_command(
449 &mut self,
450 ctx: &CommandContext,
451 script: &str,
452 options: CommandOptions,
453 ) -> Result<CommandResult<Self::Handle>> {
454 let CommandOptions {
455 mode,
456 stdin,
457 stdout,
458 stderr,
459 } = options;
460
461 if let Some(reader) = stdin
462 && let Ok(mut guard) = reader.lock()
463 {
464 let mut sink = std::io::sink();
465 let _ = std::io::copy(&mut *guard, &mut sink);
466 }
467
468 match mode {
469 CommandMode::Foreground => {
470 let needs_bytes = matches!(stdout, CommandStdout::Capture)
471 || matches!(stdout, CommandStdout::Stream(_));
472 let (out, status) = execute_sync(ctx, script, needs_bytes)?;
473 if !status.success() {
474 bail!("command {:?} failed with status {}", script, status);
475 }
476 if matches!(stderr, CommandStderr::Stream(_)) {
477 }
480 match stdout {
481 CommandStdout::Inherit => Ok(CommandResult::Completed),
482 CommandStdout::Stream(writer) => {
483 if needs_bytes && let Ok(mut guard) = writer.lock() {
484 let _ = std::io::Write::write_all(&mut *guard, &out);
485 let _ = std::io::Write::flush(&mut *guard);
486 }
487 Ok(CommandResult::Completed)
488 }
489 CommandStdout::Capture => Ok(CommandResult::Captured(out)),
490 }
491 }
492 CommandMode::Background => match stdout {
493 CommandStdout::Capture => {
494 bail!("cannot capture stdout for background command under miri")
495 }
496 CommandStdout::Stream(_) => {
497 bail!("stdout streaming not supported for background command under miri")
498 }
499 CommandStdout::Inherit => {
500 if matches!(stderr, CommandStderr::Stream(_)) {
501 bail!("stderr streaming not supported for background command under miri");
502 }
503 let plan = plan_background(ctx, script)?;
504 Ok(CommandResult::Background(plan))
505 }
506 },
507 }
508 }
509}
510
511#[cfg(miri)]
512fn execute_sync(
513 ctx: &CommandContext,
514 script: &str,
515 capture: bool,
516) -> Result<(Vec<u8>, ExitStatus)> {
517 let mut stdout = Vec::new();
518 let mut status = exit_status_from_code(0);
519 let resolver = PathResolver::new(
520 ctx.workspace_root().as_path(),
521 ctx.build_context().as_path(),
522 )?;
523
524 let script = normalize_shell(script);
525 for raw in script.split(';') {
526 let cmd = raw.trim();
527 if cmd.is_empty() {
528 continue;
529 }
530 let (action, sleep_dur, exit_code) = parse_command(cmd, ctx, &resolver, capture)?;
531 if sleep_dur > std::time::Duration::ZERO {
532 std::thread::sleep(sleep_dur);
533 }
534 if let Some(action) = action {
535 match action {
536 CommandAction::Write { target, data } => {
537 if let Some(parent) = target.as_path().parent() {
538 let parent_guard = GuardedPath::new(target.root(), parent)?;
539 resolver.create_dir_all(&parent_guard)?;
540 }
541 resolver.write_file(&target, &data)?;
542 }
543 CommandAction::Stdout { data } => {
544 stdout.extend_from_slice(&data);
545 }
546 }
547 }
548 if let Some(code) = exit_code {
549 status = exit_status_from_code(code);
550 break;
551 }
552 }
553
554 Ok((stdout, status))
555}
556
557#[cfg(miri)]
558fn plan_background(ctx: &CommandContext, script: &str) -> Result<SyntheticBgHandle> {
559 let resolver = PathResolver::new(
560 ctx.workspace_root().as_path(),
561 ctx.build_context().as_path(),
562 )?;
563 let mut actions: Vec<Action> = Vec::new();
564 let mut ready = std::time::Duration::ZERO;
565 let mut status = exit_status_from_code(0);
566
567 let script = normalize_shell(script);
568 for raw in script.split(';') {
569 let cmd = raw.trim();
570 if cmd.is_empty() {
571 continue;
572 }
573 let (action, sleep_dur, exit_code) = parse_command(cmd, ctx, &resolver, false)?;
574 ready += sleep_dur;
575 if let Some(CommandAction::Write { target, data }) = action {
576 actions.push(Action::WriteFile { target, data });
577 }
578 if let Some(code) = exit_code {
579 status = exit_status_from_code(code);
580 break;
581 }
582 }
583
584 let min_ready = std::time::Duration::from_millis(50);
585 ready = ready.max(min_ready);
586
587 let handle = SyntheticBgHandle {
588 ctx: ctx.clone(),
589 actions,
590 remaining: ready,
591 last_polled: std::time::Instant::now(),
592 status,
593 applied: false,
594 killed: false,
595 };
596 Ok(handle)
597}
598
599#[cfg(miri)]
600enum CommandAction {
601 Write { target: GuardedPath, data: Vec<u8> },
602 Stdout { data: Vec<u8> },
603}
604
605#[cfg(miri)]
606fn parse_command(
607 cmd: &str,
608 ctx: &CommandContext,
609 resolver: &PathResolver,
610 capture: bool,
611) -> Result<(Option<CommandAction>, std::time::Duration, Option<i32>)> {
612 let (core, redirect) = split_redirect(cmd);
613 let tokens: Vec<&str> = core.split_whitespace().collect();
614 if tokens.is_empty() {
615 return Ok((None, std::time::Duration::ZERO, None));
616 }
617
618 match tokens[0] {
619 "sleep" => {
620 let dur = tokens
621 .get(1)
622 .and_then(|s| s.parse::<f64>().ok())
623 .unwrap_or(0.0);
624 let duration = std::time::Duration::from_secs_f64(dur);
625 Ok((None, duration, None))
626 }
627 "exit" => {
628 let code = tokens
629 .get(1)
630 .and_then(|s| s.parse::<i32>().ok())
631 .unwrap_or(0);
632 Ok((None, std::time::Duration::ZERO, Some(code)))
633 }
634 "printf" => {
635 let body = extract_body(&core, "printf %s");
636 let expanded = expand_env(&body, ctx);
637 let data = expanded.into_bytes();
638 if let Some(path_str) = redirect {
639 let target = resolve_write(resolver, ctx, &path_str)?;
640 Ok((
641 Some(CommandAction::Write { target, data }),
642 std::time::Duration::ZERO,
643 None,
644 ))
645 } else if capture {
646 Ok((
647 Some(CommandAction::Stdout { data }),
648 std::time::Duration::ZERO,
649 None,
650 ))
651 } else {
652 Ok((None, std::time::Duration::ZERO, None))
653 }
654 }
655 "echo" => {
656 let body = core.strip_prefix("echo").unwrap_or("").trim();
657 let expanded = expand_env(body, ctx);
658 let mut data = expanded.into_bytes();
659 data.push(b'\n');
660 if let Some(path_str) = redirect {
661 let target = resolve_write(resolver, ctx, &path_str)?;
662 Ok((
663 Some(CommandAction::Write { target, data }),
664 std::time::Duration::ZERO,
665 None,
666 ))
667 } else if capture {
668 Ok((
669 Some(CommandAction::Stdout { data }),
670 std::time::Duration::ZERO,
671 None,
672 ))
673 } else {
674 Ok((None, std::time::Duration::ZERO, None))
675 }
676 }
677 _ => {
678 Ok((None, std::time::Duration::ZERO, None))
680 }
681 }
682}
683
684#[cfg(miri)]
685fn resolve_write(resolver: &PathResolver, ctx: &CommandContext, path: &str) -> Result<GuardedPath> {
686 match ctx.cwd() {
687 PolicyPath::Guarded(p) => resolver.resolve_write(p, path),
688 PolicyPath::Unguarded(_) => bail!("unguarded writes not supported in Miri"),
689 }
690}
691
692#[cfg(miri)]
693fn split_redirect(cmd: &str) -> (String, Option<String>) {
694 if let Some(idx) = cmd.find('>') {
695 let (left, right) = cmd.split_at(idx);
696 let path = right.trim_start_matches('>').trim();
697 (left.trim().to_string(), Some(path.to_string()))
698 } else {
699 (cmd.trim().to_string(), None)
700 }
701}
702
703#[cfg(miri)]
704fn extract_body(cmd: &str, prefix: &str) -> String {
705 cmd.strip_prefix(prefix)
706 .unwrap_or(cmd)
707 .trim()
708 .trim_matches('"')
709 .to_string()
710}
711
712#[cfg(miri)]
713fn expand_env(input: &str, ctx: &CommandContext) -> String {
714 let first = expand_with_lookup(input, |name| Some(env_lookup(name, ctx)));
718
719 let mut out = String::with_capacity(first.len());
720 let mut chars = first.chars().peekable();
721 while let Some(c) = chars.next() {
722 if c == '$' {
723 match chars.peek() {
724 Some('$') => {
725 out.push('$');
727 chars.next();
728 }
729 Some('{') => {
730 chars.next(); let mut name = String::new();
733 while let Some(&ch) = chars.peek() {
734 chars.next();
735 if ch == '}' {
736 break;
737 }
738 name.push(ch);
739 }
740 let val = if name == "CARGO_TARGET_DIR" {
741 ctx.cargo_target_dir().display().to_string()
742 } else {
743 ctx.envs()
744 .get(&name)
745 .cloned()
746 .or_else(|| std::env::var(&name).ok())
747 .unwrap_or_default()
748 };
749 out.push_str(&val);
750 }
751 Some(next) if next.is_alphanumeric() || *next == '_' => {
752 let mut name = String::new();
754 while let Some(&ch) = chars.peek() {
755 if ch.is_alphanumeric() || ch == '_' {
756 name.push(ch);
757 chars.next();
758 } else {
759 break;
760 }
761 }
762 let val = if name == "CARGO_TARGET_DIR" {
763 ctx.cargo_target_dir().display().to_string()
764 } else {
765 ctx.envs()
766 .get(&name)
767 .cloned()
768 .or_else(|| std::env::var(&name).ok())
769 .unwrap_or_default()
770 };
771 out.push_str(&val);
772 }
773 _ => {
774 out.push('$');
776 }
777 }
778 } else {
779 out.push(c);
780 }
781 }
782
783 out
784}
785
786#[cfg(miri)]
787fn env_lookup(name: &str, ctx: &CommandContext) -> String {
788 if name == "CARGO_TARGET_DIR" {
789 return ctx.cargo_target_dir().display().to_string();
790 }
791 ctx.envs()
792 .get(name)
793 .cloned()
794 .or_else(|| std::env::var(name).ok())
795 .unwrap_or_default()
796}
797
798#[cfg(miri)]
799fn normalize_shell(script: &str) -> String {
800 let trimmed = script.trim();
801 if let Some(rest) = trimmed.strip_prefix("sh -c ") {
802 return rest.trim_matches(&['"', '\''] as &[_]).to_string();
803 }
804 if let Some(rest) = trimmed.strip_prefix("cmd /C ") {
805 return rest.trim_matches(&['"', '\''] as &[_]).to_string();
806 }
807 trimmed.to_string()
808}
809
810#[cfg(miri)]
811fn apply_actions(ctx: &CommandContext, actions: &[Action]) -> Result<()> {
812 let resolver = PathResolver::new(
813 ctx.workspace_root().as_path(),
814 ctx.build_context().as_path(),
815 )?;
816 for action in actions {
817 match action {
818 Action::WriteFile { target, data } => {
819 if let Some(parent) = target.as_path().parent() {
820 let parent_guard = GuardedPath::new(target.root(), parent)?;
821 resolver.create_dir_all(&parent_guard)?;
822 }
823 resolver.write_file(target, data)?;
824 }
825 }
826 }
827 Ok(())
828}
829
830#[cfg(miri)]
831fn exit_status_from_code(code: i32) -> ExitStatus {
832 #[cfg(unix)]
833 {
834 use std::os::unix::process::ExitStatusExt;
835 ExitStatusExt::from_raw(code << 8)
836 }
837 #[cfg(windows)]
838 {
839 use std::os::windows::process::ExitStatusExt;
840 ExitStatusExt::from_raw(code as u32)
841 }
842}
843
844#[cfg(not(miri))]
845pub type DefaultProcessManager = ShellProcessManager;
846
847#[cfg(miri)]
848pub type DefaultProcessManager = SyntheticProcessManager;
849
850pub fn default_process_manager() -> DefaultProcessManager {
851 DefaultProcessManager::default()
852}
853
854#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
855fn spawn_child_with_streams(
856 cmd: &mut ProcessCommand,
857 stdin: Option<SharedInput>,
858 stdout: Option<SharedOutput>,
859 stderr: Option<SharedOutput>,
860) -> Result<ChildHandle> {
861 if stdin.is_some() {
862 cmd.stdin(Stdio::piped());
863 }
864 if stdout.is_some() {
865 cmd.stdout(Stdio::piped());
866 }
867 if stderr.is_some() {
868 cmd.stderr(Stdio::piped());
869 }
870
871 let mut child = cmd
872 .spawn()
873 .with_context(|| format!("failed to spawn {:?}", cmd))?;
874 let mut io_threads = Vec::new();
875
876 if let Some(stdin_stream) = stdin
877 && let Some(mut child_stdin) = child.stdin.take()
878 {
879 let thread = std::thread::spawn(move || {
880 if let Ok(mut guard) = stdin_stream.lock() {
881 let _ = std::io::copy(&mut *guard, &mut child_stdin);
882 }
883 });
884 io_threads.push(thread);
885 }
886
887 if let Some(stdout_stream) = stdout
888 && let Some(mut child_stdout) = child.stdout.take()
889 {
890 let stream_clone = stdout_stream.clone();
891 let thread = std::thread::spawn(move || {
892 let mut buf = [0u8; 1024];
893 loop {
894 match std::io::Read::read(&mut child_stdout, &mut buf) {
895 Ok(0) => break,
896 Ok(n) => {
897 if let Ok(mut guard) = stream_clone.lock() {
898 if std::io::Write::write_all(&mut *guard, &buf[..n]).is_err() {
899 break;
900 }
901 let _ = std::io::Write::flush(&mut *guard);
902 }
903 }
904 Err(_) => break,
905 }
906 }
907 });
908 io_threads.push(thread);
909 }
910
911 if let Some(stderr_stream) = stderr
912 && let Some(mut child_stderr) = child.stderr.take()
913 {
914 let stream_clone = stderr_stream.clone();
915 let thread = std::thread::spawn(move || {
916 let mut buf = [0u8; 1024];
917 loop {
918 match std::io::Read::read(&mut child_stderr, &mut buf) {
919 Ok(0) => break,
920 Ok(n) => {
921 if let Ok(mut guard) = stream_clone.lock() {
922 if std::io::Write::write_all(&mut *guard, &buf[..n]).is_err() {
923 break;
924 }
925 let _ = std::io::Write::flush(&mut *guard);
926 }
927 }
928 Err(_) => break,
929 }
930 }
931 });
932 io_threads.push(thread);
933 }
934
935 Ok(ChildHandle { child, io_threads })
936}
937
938#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
940pub struct CommandBuilder {
941 inner: ProcessCommand,
942 program: OsString,
943 args: Vec<OsString>,
944 cwd: Option<PathBuf>,
945}
946
947#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
948impl CommandBuilder {
949 pub fn new(program: impl AsRef<OsStr>) -> Self {
950 let prog = program.as_ref().to_os_string();
951 Self {
952 inner: ProcessCommand::new(&prog),
953 program: prog,
954 args: Vec::new(),
955 cwd: None,
956 }
957 }
958
959 pub fn arg(&mut self, arg: impl AsRef<OsStr>) -> &mut Self {
960 let val = arg.as_ref().to_os_string();
961 self.inner.arg(&val);
962 self.args.push(val);
963 self
964 }
965
966 pub fn args<S, I>(&mut self, args: I) -> &mut Self
967 where
968 S: AsRef<OsStr>,
969 I: IntoIterator<Item = S>,
970 {
971 for arg in args {
972 self.arg(arg);
973 }
974 self
975 }
976
977 pub fn env(&mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> &mut Self {
978 self.inner.env(key, value);
979 self
980 }
981
982 pub fn env_remove(&mut self, key: impl AsRef<OsStr>) -> &mut Self {
983 self.inner.env_remove(key);
984 self
985 }
986
987 pub fn stdin_file(&mut self, file: File) -> &mut Self {
988 self.inner.stdin(Stdio::from(file));
989 self
990 }
991
992 pub fn current_dir(&mut self, dir: impl AsRef<Path>) -> &mut Self {
993 let path = dir.as_ref();
994 self.inner.current_dir(path);
995 self.cwd = Some(path.to_path_buf());
996 self
997 }
998
999 pub fn status(&mut self) -> Result<ExitStatus> {
1000 #[cfg(miri)]
1001 {
1002 let snap = self.snapshot();
1003 synthetic_status(&snap)
1004 }
1005
1006 #[cfg(not(miri))]
1007 {
1008 let desc = format!("{:?}", self.inner);
1009 let status = self
1010 .inner
1011 .status()
1012 .with_context(|| format!("failed to run {desc}"))?;
1013 Ok(status)
1014 }
1015 }
1016
1017 pub fn output(&mut self) -> Result<CommandOutput> {
1018 #[cfg(miri)]
1019 {
1020 let snap = self.snapshot();
1021 synthetic_output(&snap)
1022 }
1023
1024 #[cfg(not(miri))]
1025 {
1026 let desc = format!("{:?}", self.inner);
1027 let out = self
1028 .inner
1029 .output()
1030 .with_context(|| format!("failed to run {desc}"))?;
1031 Ok(CommandOutput::from(out))
1032 }
1033 }
1034
1035 pub fn spawn(&mut self) -> Result<ChildHandle> {
1036 #[cfg(miri)]
1037 {
1038 bail!("spawn is not supported under miri synthetic process backend")
1039 }
1040
1041 #[cfg(not(miri))]
1042 {
1043 let desc = format!("{:?}", self.inner);
1044 let child = self
1045 .inner
1046 .spawn()
1047 .with_context(|| format!("failed to spawn {desc}"))?;
1048 Ok(ChildHandle {
1049 child,
1050 io_threads: Vec::new(),
1051 })
1052 }
1053 }
1054
1055 pub fn snapshot(&self) -> CommandSnapshot {
1057 CommandSnapshot {
1058 program: self.program.clone(),
1059 args: self.args.clone(),
1060 cwd: self.cwd.clone(),
1061 }
1062 }
1063}
1064
1065#[derive(Clone, Debug, PartialEq, Eq)]
1066#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
1067pub struct CommandSnapshot {
1068 pub program: OsString,
1069 pub args: Vec<OsString>,
1070 pub cwd: Option<PathBuf>,
1071}
1072
1073pub struct CommandOutput {
1074 pub status: ExitStatus,
1075 pub stdout: Vec<u8>,
1076 pub stderr: Vec<u8>,
1077}
1078
1079impl CommandOutput {
1080 pub fn success(&self) -> bool {
1081 self.status.success()
1082 }
1083}
1084
1085#[allow(clippy::disallowed_types)]
1086impl From<StdOutput> for CommandOutput {
1087 fn from(value: StdOutput) -> Self {
1088 Self {
1089 status: value.status,
1090 stdout: value.stdout,
1091 stderr: value.stderr,
1092 }
1093 }
1094}
1095
1096#[cfg(miri)]
1097fn synthetic_status(snapshot: &CommandSnapshot) -> Result<ExitStatus> {
1098 Ok(synthetic_output(snapshot)?.status)
1099}
1100
1101#[cfg(miri)]
1102fn synthetic_output(snapshot: &CommandSnapshot) -> Result<CommandOutput> {
1103 let program = snapshot.program.to_string_lossy().to_string();
1104 let args: Vec<String> = snapshot
1105 .args
1106 .iter()
1107 .map(|a| a.to_string_lossy().to_string())
1108 .collect();
1109
1110 if program == "git" {
1111 return simulate_git(&args);
1112 }
1113 if program == "cargo" {
1114 return simulate_cargo(&args);
1115 }
1116
1117 Ok(CommandOutput {
1118 status: exit_status_from_code(0),
1119 stdout: Vec::new(),
1120 stderr: Vec::new(),
1121 })
1122}
1123
1124#[cfg(miri)]
1125fn simulate_git(args: &[String]) -> Result<CommandOutput> {
1126 let mut iter = args.iter();
1127 if matches!(iter.next(), Some(arg) if arg == "-C") {
1128 let _ = iter.next();
1129 }
1130 let remaining: Vec<String> = iter.map(|s| s.to_string()).collect();
1131
1132 if remaining.len() >= 2 && remaining[0] == "rev-parse" && remaining[1] == "HEAD" {
1133 return Ok(CommandOutput {
1134 status: exit_status_from_code(0),
1135 stdout: b"HEAD\n".to_vec(),
1136 stderr: Vec::new(),
1137 });
1138 }
1139
1140 Ok(CommandOutput {
1142 status: exit_status_from_code(0),
1143 stdout: Vec::new(),
1144 stderr: Vec::new(),
1145 })
1146}
1147
1148#[cfg(miri)]
1149fn simulate_cargo(args: &[String]) -> Result<CommandOutput> {
1150 let mut status = exit_status_from_code(0);
1152 if args.iter().any(|a| a.contains("build_exit_fail")) {
1153 status = exit_status_from_code(1);
1154 }
1155 Ok(CommandOutput {
1156 status,
1157 stdout: Vec::new(),
1158 stderr: Vec::new(),
1159 })
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164 use super::*;
1165 use oxdock_sys_test_utils::TestEnvGuard;
1166 use std::collections::HashMap;
1167
1168 #[test]
1169 fn expand_script_env_prefers_script_values() {
1170 let mut script_envs = HashMap::new();
1171 script_envs.insert("FOO".into(), "from-script".into());
1172 script_envs.insert("ONLY".into(), "only".into());
1173 let _env_guard = TestEnvGuard::set("FOO", "from-env");
1174 let rendered = expand_script_env(
1175 "{{ env:FOO }}:{{ env:ONLY }}:{{ env:MISSING }}",
1176 &script_envs,
1177 );
1178 assert_eq!(rendered, "from-script:only:");
1179 }
1180
1181 #[test]
1182 fn expand_script_env_supports_colon_separator() {
1183 let mut script_envs = HashMap::new();
1184 script_envs.insert("FOO".into(), "val".into());
1185 let rendered = expand_script_env("{{ env:FOO }}", &script_envs);
1186 assert_eq!(rendered, "val");
1187 }
1188
1189 #[test]
1190 fn expand_command_env_handles_var_forms() {
1191 let temp = GuardedPath::tempdir().expect("tempdir");
1192 let guard = temp.as_guarded_path().clone();
1193 let cwd: PolicyPath = guard.clone().into();
1194 let mut envs = HashMap::new();
1195 envs.insert("FOO".into(), "bar".into());
1196 envs.insert("PCT".into(), "percent".into());
1197 envs.insert("CARGO_TARGET_DIR".into(), guard.display().to_string());
1198 envs.insert("HOST_ONLY".into(), "host".into());
1199
1200 let ctx = CommandContext::new(&cwd, &envs, &guard, &guard, &guard);
1201
1202 let rendered = expand_command_env(
1204 "{{ env:FOO }}-{{ env:PCT }}-{{ env:HOST_ONLY }}-{{ env:CARGO_TARGET_DIR }}",
1205 &ctx,
1206 );
1207 assert_eq!(rendered, format!("bar-percent-host-{}", guard.display()));
1208
1209 let input_literal = "%FOO%-{CARGO_TARGET_DIR}-$$";
1214 let rendered_literal = expand_command_env(input_literal, &ctx);
1215 assert_eq!(rendered_literal, input_literal);
1216 }
1217
1218 #[test]
1219 fn expand_command_env_does_not_fallback_to_host() {
1220 let temp = GuardedPath::tempdir().expect("tempdir");
1221 let guard = temp.as_guarded_path().clone();
1222 let cwd: PolicyPath = guard.clone().into();
1223 let envs = HashMap::new();
1224 let _env_guard = TestEnvGuard::set("HOST_ONLY", "host");
1225
1226 let ctx = CommandContext::new(&cwd, &envs, &guard, &guard, &guard);
1227 let rendered = expand_command_env("{{ env:HOST_ONLY }}", &ctx);
1228 assert_eq!(rendered, "");
1229 }
1230}