Skip to main content

oxide_batch_cli/
host.rs

1//! The process boundary one invocation reads and writes.
2//!
3//! Every environment variable, file read, byte written, confirmation prompt,
4//! and generated operation identifier passes through [`Host`]. Tests supply a
5//! deterministic host, so broken output, refused confirmation, file
6//! permissions, and per-value precedence are ordinary assertions rather than
7//! process-level fixtures.
8
9use std::io::{self, IsTerminal, Read, Write};
10use std::path::Path;
11
12/// The process services one invocation requires.
13pub trait Host {
14    /// Reads one environment variable.
15    ///
16    /// A variable that is present but empty is treated as absent, so an empty
17    /// value never shadows a configuration file.
18    fn env(&self, key: &str) -> Option<String>;
19
20    /// Reads one file.
21    ///
22    /// # Errors
23    ///
24    /// Returns the underlying input/output failure.
25    fn read_file(&self, path: &Path) -> io::Result<Vec<u8>>;
26
27    /// Returns the Unix permission bits of a file, when the platform has them.
28    ///
29    /// # Errors
30    ///
31    /// Returns the underlying metadata failure.
32    fn file_mode(&self, path: &Path) -> io::Result<Option<u32>>;
33
34    /// Atomically writes a new diagnostics-bundle directory.
35    ///
36    /// The target must not already exist. File names are framework-owned,
37    /// deterministic base names without path traversal.
38    ///
39    /// # Errors
40    ///
41    /// Returns an input/output failure when the target exists, a name is not
42    /// accepted, or the atomic directory write cannot complete.
43    fn write_new_directory(
44        &mut self,
45        _path: &Path,
46        _files: &[(String, Vec<u8>)],
47    ) -> io::Result<()> {
48        Err(io::Error::new(
49            io::ErrorKind::Unsupported,
50            "this host does not support diagnostics bundle output",
51        ))
52    }
53
54    /// Writes to standard output.
55    ///
56    /// # Errors
57    ///
58    /// Returns the underlying write failure, including a closed pipe.
59    fn write_stdout(&mut self, bytes: &[u8]) -> io::Result<()>;
60
61    /// Flushes standard output.
62    ///
63    /// # Errors
64    ///
65    /// Returns the underlying flush failure.
66    fn flush_stdout(&mut self) -> io::Result<()>;
67
68    /// Writes a redacted diagnostic to standard error.
69    ///
70    /// A diagnostic write failure never changes the exit category, because the
71    /// command's durable effect does not depend on it.
72    fn write_stderr(&mut self, bytes: &[u8]);
73
74    /// Returns whether standard input is an interactive terminal.
75    fn is_stdin_interactive(&self) -> bool;
76
77    /// Returns whether standard output is a terminal that can carry styling.
78    fn is_stdout_terminal(&self) -> bool;
79
80    /// Reads one confirmation response from standard input.
81    ///
82    /// Returns `None` when input ended without a response. An empty response
83    /// is never confirmation.
84    ///
85    /// # Errors
86    ///
87    /// Returns the underlying read failure.
88    fn read_confirmation(&mut self) -> io::Result<Option<String>>;
89
90    /// Returns a fresh operation identifier for an interactive mutation.
91    ///
92    /// The value is printed before the effect is attempted so that an operator
93    /// can replay the request after an ambiguous outcome.
94    fn new_operation_id(&mut self) -> String;
95}
96
97/// The real process host.
98#[derive(Debug)]
99pub struct ProcessHost {
100    stdout: io::Stdout,
101    stderr: io::Stderr,
102    counter: u64,
103}
104
105impl ProcessHost {
106    /// Binds the current process streams.
107    #[must_use]
108    pub fn new() -> Self {
109        Self {
110            stdout: io::stdout(),
111            stderr: io::stderr(),
112            counter: 0,
113        }
114    }
115}
116
117impl Default for ProcessHost {
118    fn default() -> Self {
119        Self::new()
120    }
121}
122
123impl Host for ProcessHost {
124    fn env(&self, key: &str) -> Option<String> {
125        std::env::var(key).ok().filter(|value| !value.is_empty())
126    }
127
128    fn read_file(&self, path: &Path) -> io::Result<Vec<u8>> {
129        std::fs::read(path)
130    }
131
132    fn file_mode(&self, path: &Path) -> io::Result<Option<u32>> {
133        let metadata = std::fs::metadata(path)?;
134        #[cfg(unix)]
135        {
136            use std::os::unix::fs::PermissionsExt;
137            Ok(Some(metadata.permissions().mode()))
138        }
139        #[cfg(not(unix))]
140        {
141            let _ = metadata;
142            Ok(None)
143        }
144    }
145
146    fn write_new_directory(&mut self, path: &Path, files: &[(String, Vec<u8>)]) -> io::Result<()> {
147        if path.try_exists()? {
148            return Err(io::Error::new(
149                io::ErrorKind::AlreadyExists,
150                "the bundle target already exists",
151            ));
152        }
153        let mut temporary = path.as_os_str().to_owned();
154        temporary.push(format!(".tmp-{}", std::process::id()));
155        let temporary = std::path::PathBuf::from(temporary);
156        if temporary.try_exists()? {
157            return Err(io::Error::new(
158                io::ErrorKind::AlreadyExists,
159                "the bundle temporary target already exists",
160            ));
161        }
162        std::fs::create_dir(&temporary)?;
163        let written = files.iter().try_for_each(|(name, bytes)| {
164            if name.is_empty()
165                || name.contains('/')
166                || name.contains('\\')
167                || matches!(name.as_str(), "." | "..")
168            {
169                return Err(io::Error::new(
170                    io::ErrorKind::InvalidInput,
171                    "the bundle file name is not accepted",
172                ));
173            }
174            let target = temporary.join(name);
175            let mut file = std::fs::OpenOptions::new()
176                .write(true)
177                .create_new(true)
178                .open(target)?;
179            file.write_all(bytes)?;
180            file.sync_all()
181        });
182        if let Err(error) = written {
183            let _ = std::fs::remove_dir_all(&temporary);
184            return Err(error);
185        }
186        if let Err(error) = std::fs::create_dir(path) {
187            let _ = std::fs::remove_dir_all(&temporary);
188            return Err(error);
189        }
190        let installed = files
191            .iter()
192            .try_for_each(|(name, _)| std::fs::rename(temporary.join(name), path.join(name)));
193        if let Err(error) = installed {
194            let _ = std::fs::remove_dir_all(path);
195            let _ = std::fs::remove_dir_all(&temporary);
196            return Err(error);
197        }
198        std::fs::remove_dir(&temporary)?;
199        Ok(())
200    }
201
202    fn write_stdout(&mut self, bytes: &[u8]) -> io::Result<()> {
203        self.stdout.write_all(bytes)
204    }
205
206    fn flush_stdout(&mut self) -> io::Result<()> {
207        self.stdout.flush()
208    }
209
210    fn write_stderr(&mut self, bytes: &[u8]) {
211        let _ = self.stderr.write_all(bytes);
212        let _ = self.stderr.flush();
213    }
214
215    fn is_stdin_interactive(&self) -> bool {
216        io::stdin().is_terminal()
217    }
218
219    fn is_stdout_terminal(&self) -> bool {
220        self.stdout.is_terminal()
221    }
222
223    fn read_confirmation(&mut self) -> io::Result<Option<String>> {
224        let mut buffer = String::new();
225        let mut handle = io::stdin().lock();
226        let mut byte = [0_u8; 1];
227        loop {
228            match handle.read(&mut byte)? {
229                0 => break,
230                _ if byte[0] == b'\n' => break,
231                _ => buffer.push(char::from(byte[0])),
232            }
233            if buffer.len() > MAX_CONFIRMATION_BYTES {
234                break;
235            }
236        }
237        if buffer.is_empty() {
238            return Ok(None);
239        }
240        Ok(Some(buffer))
241    }
242
243    fn new_operation_id(&mut self) -> String {
244        // The identifier only needs to be unique for one operator's replay of
245        // one request, so process identity plus a monotonic counter is
246        // sufficient and adds no dependency.
247        self.counter += 1;
248        let nanos = std::time::SystemTime::now()
249            .duration_since(std::time::UNIX_EPOCH)
250            .map_or(0, |value| value.as_nanos());
251        format!("cli-{}-{nanos}-{}", std::process::id(), self.counter)
252    }
253}
254
255/// The largest confirmation response the CLI reads.
256const MAX_CONFIRMATION_BYTES: usize = 64;
257
258#[cfg(test)]
259pub(crate) mod testing {
260    use std::collections::BTreeMap;
261    use std::io;
262    use std::path::{Path, PathBuf};
263
264    use super::Host;
265
266    /// A deterministic in-memory host.
267    #[derive(Debug, Default)]
268    pub(crate) struct TestHost {
269        pub(crate) env: BTreeMap<String, String>,
270        pub(crate) files: BTreeMap<PathBuf, Vec<u8>>,
271        pub(crate) modes: BTreeMap<PathBuf, u32>,
272        pub(crate) directories: BTreeMap<PathBuf, Vec<String>>,
273        pub(crate) stdout: Vec<u8>,
274        pub(crate) stderr: Vec<u8>,
275        pub(crate) stdin_interactive: bool,
276        pub(crate) stdout_terminal: bool,
277        pub(crate) confirmation: Option<String>,
278        /// Number of bytes standard output accepts before it fails.
279        pub(crate) stdout_capacity: Option<usize>,
280        pub(crate) operation_ids: u64,
281    }
282
283    impl TestHost {
284        pub(crate) fn new() -> Self {
285            Self::default()
286        }
287
288        pub(crate) fn with_env(mut self, key: &str, value: &str) -> Self {
289            self.env.insert(key.to_owned(), value.to_owned());
290            self
291        }
292
293        pub(crate) fn with_file(mut self, path: &str, contents: &str) -> Self {
294            self.files
295                .insert(PathBuf::from(path), contents.as_bytes().to_vec());
296            self.modes.insert(PathBuf::from(path), 0o600);
297            self
298        }
299
300        pub(crate) fn with_mode(mut self, path: &str, mode: u32) -> Self {
301            self.modes.insert(PathBuf::from(path), mode);
302            self
303        }
304
305        pub(crate) fn with_stdout_capacity(mut self, bytes: usize) -> Self {
306            self.stdout_capacity = Some(bytes);
307            self
308        }
309
310        pub(crate) fn stdout_text(&self) -> String {
311            String::from_utf8_lossy(&self.stdout).into_owned()
312        }
313    }
314
315    impl Host for TestHost {
316        fn env(&self, key: &str) -> Option<String> {
317            self.env.get(key).cloned().filter(|value| !value.is_empty())
318        }
319
320        fn read_file(&self, path: &Path) -> io::Result<Vec<u8>> {
321            self.files.get(path).cloned().ok_or_else(|| {
322                io::Error::new(io::ErrorKind::NotFound, "the test host has no such file")
323            })
324        }
325
326        fn file_mode(&self, path: &Path) -> io::Result<Option<u32>> {
327            if !self.files.contains_key(path) {
328                return Err(io::Error::new(
329                    io::ErrorKind::NotFound,
330                    "the test host has no such file",
331                ));
332            }
333            Ok(self.modes.get(path).copied())
334        }
335
336        fn write_new_directory(
337            &mut self,
338            path: &Path,
339            files: &[(String, Vec<u8>)],
340        ) -> io::Result<()> {
341            if self.directories.contains_key(path) {
342                return Err(io::Error::new(
343                    io::ErrorKind::AlreadyExists,
344                    "target exists",
345                ));
346            }
347            self.directories.insert(
348                path.to_path_buf(),
349                files.iter().map(|(name, _)| name.clone()).collect(),
350            );
351            for (name, bytes) in files {
352                self.files.insert(path.join(name), bytes.clone());
353            }
354            Ok(())
355        }
356
357        fn write_stdout(&mut self, bytes: &[u8]) -> io::Result<()> {
358            if let Some(capacity) = self.stdout_capacity
359                && self.stdout.len() + bytes.len() > capacity
360            {
361                return Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed pipe"));
362            }
363            self.stdout.extend_from_slice(bytes);
364            Ok(())
365        }
366
367        fn flush_stdout(&mut self) -> io::Result<()> {
368            Ok(())
369        }
370
371        fn write_stderr(&mut self, bytes: &[u8]) {
372            self.stderr.extend_from_slice(bytes);
373        }
374
375        fn is_stdin_interactive(&self) -> bool {
376            self.stdin_interactive
377        }
378
379        fn is_stdout_terminal(&self) -> bool {
380            self.stdout_terminal
381        }
382
383        fn read_confirmation(&mut self) -> io::Result<Option<String>> {
384            Ok(self.confirmation.take())
385        }
386
387        fn new_operation_id(&mut self) -> String {
388            self.operation_ids += 1;
389            format!("test-operation-{}", self.operation_ids)
390        }
391    }
392}