synwire_sandbox/output.rs
1//! Output capture for non-interactive sandbox processes.
2//!
3//! [`CapturedOutput`] stores stdout and stderr in a temporary directory that is
4//! automatically removed when the last reference is dropped, giving Go-`defer`
5//! lifecycle semantics.
6
7use std::path::PathBuf;
8use std::sync::Arc;
9
10// ── OutputMode ───────────────────────────────────────────────────────────────
11
12/// How stdout and stderr are captured for non-interactive processes.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum OutputMode {
16 /// stdout and stderr go to separate files (`stdout`, `stderr`).
17 Separate,
18 /// stdout and stderr interleave into a single file (`output`).
19 Combined,
20}
21
22// ── CapturedOutput ───────────────────────────────────────────────────────────
23
24/// Captured stdout/stderr from a non-interactive sandbox process.
25///
26/// Wraps a [`tempfile::TempDir`] that is automatically removed when the last
27/// `Arc<CapturedOutput>` is dropped (either from the [`ProcessCapture`] handle
28/// or from the [`ProcessRecord`](crate::ProcessRecord) in the registry).
29#[derive(Debug)]
30pub struct CapturedOutput {
31 /// Temporary directory that owns the output files.
32 dir: tempfile::TempDir,
33 /// How stdout and stderr are stored.
34 mode: OutputMode,
35}
36
37impl CapturedOutput {
38 /// Allocate a new output capture directory.
39 ///
40 /// # Errors
41 ///
42 /// Returns an [`std::io::Error`] if the directory cannot be created.
43 pub fn new(mode: OutputMode) -> std::io::Result<Self> {
44 Ok(Self {
45 dir: tempfile::TempDir::with_prefix("synwire-")?,
46 mode,
47 })
48 }
49
50 /// Path to the stdout output file (or combined output file).
51 #[must_use]
52 pub fn stdout_path(&self) -> PathBuf {
53 match self.mode {
54 OutputMode::Combined => self.dir.path().join("output"),
55 OutputMode::Separate => self.dir.path().join("stdout"),
56 }
57 }
58
59 /// Path to the stderr output file, or `None` when streams are combined.
60 #[must_use]
61 pub fn stderr_path(&self) -> Option<PathBuf> {
62 match self.mode {
63 OutputMode::Separate => Some(self.dir.path().join("stderr")),
64 OutputMode::Combined => None,
65 }
66 }
67
68 /// Read captured stdout (or combined output) as a UTF-8 string.
69 ///
70 /// Returns an empty string if the file does not yet exist (process has not
71 /// produced any output).
72 ///
73 /// # Errors
74 ///
75 /// Returns an [`std::io::Error`] if the file exists but cannot be read.
76 pub fn read_stdout(&self) -> std::io::Result<String> {
77 let path = self.stdout_path();
78 if path.exists() {
79 std::fs::read_to_string(path)
80 } else {
81 Ok(String::new())
82 }
83 }
84
85 /// Read captured stderr as a UTF-8 string.
86 ///
87 /// Returns `None` when using [`OutputMode::Combined`] (use
88 /// [`read_stdout`](Self::read_stdout) instead). Returns an empty string if
89 /// the stderr file does not yet exist.
90 ///
91 /// # Errors
92 ///
93 /// Returns an [`std::io::Error`] if the file exists but cannot be read.
94 pub fn read_stderr(&self) -> std::io::Result<Option<String>> {
95 match self.stderr_path() {
96 Some(p) if p.exists() => std::fs::read_to_string(p).map(Some),
97 Some(_) => Ok(Some(String::new())),
98 None => Ok(None),
99 }
100 }
101
102 /// The capture mode.
103 #[must_use]
104 pub const fn mode(&self) -> OutputMode {
105 self.mode
106 }
107}
108
109// ── ProcessCapture ───────────────────────────────────────────────────────────
110
111/// Handle returned by [`NamespaceContainer::spawn_captured`](crate::platform::linux::namespace::NamespaceContainer::spawn_captured).
112///
113/// `output` is an [`Arc`]-wrapped [`CapturedOutput`]; the temp directory lives
114/// as long as this handle or any [`ProcessRecord`](crate::ProcessRecord)
115/// referring to the same `Arc` is alive. Pass `child` to
116/// [`monitor_child`](crate::process_registry::monitor_child) for automatic
117/// registry status updates when the process exits.
118#[derive(Debug)]
119pub struct ProcessCapture {
120 /// Shared reference to the captured output directory.
121 pub output: Arc<CapturedOutput>,
122 /// The running child process.
123 pub child: tokio::process::Child,
124 /// OCI bundle directory — kept alive while the container runs.
125 /// `None` for non-container processes.
126 pub(crate) _bundle: Option<tempfile::TempDir>,
127}