Skip to main content

shrub_core/
openfiles.rs

1//! Managing files open within a shell instance.
2
3use std::collections::HashMap;
4use std::io::IsTerminal;
5use std::process::Stdio;
6use std::sync::Arc;
7
8use crate::{ShellFd, error, sys};
9
10/// A trait representing a stream that can be read from and written to.
11/// This is used for custom stream implementations in `OpenFile`.
12///
13/// Types that implement this trait are expected to be cloneable via the
14/// `clone_box` function.
15pub trait Stream: std::io::Read + std::io::Write + Send + Sync {
16    /// Clones the stream into a boxed trait object.
17    fn clone_box(&self) -> Box<dyn Stream>;
18
19    /// Converts the stream into an `OwnedFd`. Returns an error if the operation
20    /// is not supported or if it fails.
21    ///
22    /// # Errors
23    ///
24    /// Implementations return an error if the stream has no underlying
25    /// descriptor or duplicating it fails.
26    #[cfg(unix)] // ast-grep-ignore: platform-cfg-outside-sys
27    fn try_clone_to_owned(&self) -> Result<std::os::fd::OwnedFd, error::Error>;
28
29    /// Borrows the stream as a `BorrowedFd`. Returns an error if the operation
30    /// is not supported or if it fails.
31    ///
32    /// # Errors
33    ///
34    /// Implementations return an error if the stream has no underlying
35    /// descriptor.
36    #[cfg(unix)] // ast-grep-ignore: platform-cfg-outside-sys
37    fn try_borrow_as_fd(&self) -> Result<std::os::fd::BorrowedFd<'_>, error::Error>;
38}
39
40/// Represents a file open in a shell context.
41///
42/// File- and pipe-backed variants hold their handle behind an [`Arc`], so
43/// cloning an `OpenFile` shares the underlying descriptor by reference count.
44/// The shell opens a fresh context for each subshell, command substitution,
45/// background job, and function call and runs them as in-process tasks against
46/// one process-wide descriptor table (rather than via `fork(2)` like a
47/// traditional shell); sharing the descriptor keeps deeply nested or highly
48/// concurrent execution from exhausting that table. A descriptor is duplicated
49/// for real only when an independently owned copy is needed to hand to an
50/// external child process — see [`OpenFile::try_clone_to_owned`] and
51/// the `Stdio` conversion below.
52pub enum OpenFile {
53    /// The original standard input this process was started with.
54    Stdin(std::io::Stdin),
55    /// The original standard output this process was started with.
56    Stdout(std::io::Stdout),
57    /// The original standard error this process was started with.
58    Stderr(std::io::Stderr),
59    /// A file open for reading or writing.
60    File(Arc<std::fs::File>),
61    /// A read end of a pipe.
62    PipeReader(Arc<std::io::PipeReader>),
63    /// A write end of a pipe.
64    PipeWriter(Arc<std::io::PipeWriter>),
65    /// A custom stream.
66    Stream(Box<dyn Stream>),
67}
68
69#[cfg(feature = "serde")]
70impl serde::Serialize for OpenFile {
71    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
72    where
73        S: serde::Serializer,
74    {
75        match self {
76            Self::Stdin(_) => serializer.serialize_str("stdin"),
77            Self::Stdout(_) => serializer.serialize_str("stdout"),
78            Self::Stderr(_) => serializer.serialize_str("stderr"),
79            Self::File(_) => serializer.serialize_str("file"),
80            Self::PipeReader(_) => serializer.serialize_str("pipe_reader"),
81            Self::PipeWriter(_) => serializer.serialize_str("pipe_writer"),
82            Self::Stream(_) => serializer.serialize_str("stream"),
83        }
84    }
85}
86
87#[cfg(feature = "serde")]
88impl<'de> serde::Deserialize<'de> for OpenFile {
89    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
90    where
91        D: serde::Deserializer<'de>,
92    {
93        match String::deserialize(deserializer)?.as_str() {
94            "stdin" => return Ok(std::io::stdin().into()),
95            "stdout" => return Ok(std::io::stdout().into()),
96            "stderr" => return Ok(std::io::stderr().into()),
97            "file" => (),
98            "pipe_reader" => (),
99            "pipe_writer" => (),
100            "stream" => (),
101            _ => return Err(serde::de::Error::custom("invalid open file")),
102        }
103
104        // TODO(serde): Figure out something better to do with open pipes and
105        // files.
106        null().map_err(serde::de::Error::custom)
107    }
108}
109
110/// Returns an open file that will discard all I/O.
111///
112/// # Errors
113///
114/// Returns an error if the platform null device cannot be opened.
115pub fn null() -> Result<OpenFile, error::Error> {
116    let file = sys::fs::open_null_file()?;
117    Ok(file.into())
118}
119
120impl Clone for OpenFile {
121    fn clone(&self) -> Self {
122        match self {
123            Self::Stdin(_) => std::io::stdin().into(),
124            Self::Stdout(_) => std::io::stdout().into(),
125            Self::Stderr(_) => std::io::stderr().into(),
126            // File and pipe handles are shared by reference count; cloning never issues a
127            // syscall and so cannot fail.
128            Self::File(f) => Self::File(Arc::clone(f)),
129            Self::PipeReader(r) => Self::PipeReader(Arc::clone(r)),
130            Self::PipeWriter(w) => Self::PipeWriter(Arc::clone(w)),
131            Self::Stream(s) => Self::Stream(s.clone_box()),
132        }
133    }
134}
135
136impl std::fmt::Display for OpenFile {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        match self {
139            Self::Stdin(_) => write!(f, "stdin"),
140            Self::Stdout(_) => write!(f, "stdout"),
141            Self::Stderr(_) => write!(f, "stderr"),
142            Self::File(_) => write!(f, "file"),
143            Self::PipeReader(_) => write!(f, "pipe reader"),
144            Self::PipeWriter(_) => write!(f, "pipe writer"),
145            Self::Stream(_) => write!(f, "stream"),
146        }
147    }
148}
149
150impl OpenFile {
151    /// Converts the open file into an `OwnedFd`. For shared file/pipe handles
152    /// this materializes a real duplicate via `dup(2)` so the caller
153    /// receives an independently owned descriptor.
154    #[cfg(unix)] // ast-grep-ignore: platform-cfg-outside-sys
155    pub(crate) fn try_clone_to_owned(self) -> Result<std::os::fd::OwnedFd, error::Error> {
156        use std::os::fd::AsFd as _;
157
158        match self {
159            Self::Stdin(f) => Ok(f.as_fd().try_clone_to_owned()?),
160            Self::Stdout(f) => Ok(f.as_fd().try_clone_to_owned()?),
161            Self::Stderr(f) => Ok(f.as_fd().try_clone_to_owned()?),
162            Self::File(f) => Ok(f.as_fd().try_clone_to_owned()?),
163            Self::PipeReader(r) => Ok(r.as_fd().try_clone_to_owned()?),
164            Self::PipeWriter(w) => Ok(w.as_fd().try_clone_to_owned()?),
165            Self::Stream(s) => s.try_clone_to_owned(),
166        }
167    }
168
169    /// Borrows the open file as a `BorrowedFd`.
170    ///
171    /// # Errors
172    ///
173    /// Returns an error if the operation is not supported for the underlying
174    /// file type.
175    #[cfg(unix)] // ast-grep-ignore: platform-cfg-outside-sys
176    pub fn try_borrow_as_fd(&self) -> Result<std::os::fd::BorrowedFd<'_>, error::Error> {
177        use std::os::fd::AsFd as _;
178
179        match self {
180            Self::Stdin(f) => Ok(f.as_fd()),
181            Self::Stdout(f) => Ok(f.as_fd()),
182            Self::Stderr(f) => Ok(f.as_fd()),
183            Self::File(f) => Ok(f.as_fd()),
184            Self::PipeReader(r) => Ok(r.as_fd()),
185            Self::PipeWriter(w) => Ok(w.as_fd()),
186            Self::Stream(s) => s.try_borrow_as_fd(),
187        }
188    }
189
190    pub(crate) fn is_dir(&self) -> bool {
191        match self {
192            Self::Stdin(_) | Self::Stdout(_) | Self::Stderr(_) => false,
193            Self::File(file) => file.metadata().is_ok_and(|m| m.is_dir()),
194            Self::PipeReader(_) | Self::PipeWriter(_) | Self::Stream(_) => false,
195        }
196    }
197
198    /// Checks if the open file is associated with a terminal.
199    pub fn is_terminal(&self) -> bool {
200        match self {
201            Self::Stdin(f) => f.is_terminal(),
202            Self::Stdout(f) => f.is_terminal(),
203            Self::Stderr(f) => f.is_terminal(),
204            Self::File(f) => f.is_terminal(),
205            Self::PipeReader(_) | Self::PipeWriter(_) | Self::Stream(_) => false,
206        }
207    }
208}
209
210impl From<std::io::Stdin> for OpenFile {
211    /// Creates an `OpenFile` from standard input.
212    fn from(stdin: std::io::Stdin) -> Self {
213        Self::Stdin(stdin)
214    }
215}
216
217impl From<std::io::Stdout> for OpenFile {
218    /// Creates an `OpenFile` from standard output.
219    fn from(stdout: std::io::Stdout) -> Self {
220        Self::Stdout(stdout)
221    }
222}
223
224impl From<std::io::Stderr> for OpenFile {
225    /// Creates an `OpenFile` from standard error.
226    fn from(stderr: std::io::Stderr) -> Self {
227        Self::Stderr(stderr)
228    }
229}
230
231impl From<std::fs::File> for OpenFile {
232    fn from(file: std::fs::File) -> Self {
233        Self::File(Arc::new(file))
234    }
235}
236
237impl From<std::io::PipeReader> for OpenFile {
238    fn from(reader: std::io::PipeReader) -> Self {
239        Self::PipeReader(Arc::new(reader))
240    }
241}
242
243impl From<std::io::PipeWriter> for OpenFile {
244    fn from(writer: std::io::PipeWriter) -> Self {
245        Self::PipeWriter(Arc::new(writer))
246    }
247}
248
249impl TryFrom<OpenFile> for Stdio {
250    type Error = error::Error;
251
252    fn try_from(open_file: OpenFile) -> Result<Self, Self::Error> {
253        // File and pipe handles are shared behind an `Arc`, so the descriptor
254        // cannot be moved out; duplicate it to give the child an
255        // independently owned descriptor. Duplication can fail (e.g.
256        // under descriptor exhaustion), so the conversion is fallible and the
257        // error is surfaced to the caller rather than silently
258        // degrading the child's streams.
259        match open_file {
260            OpenFile::Stdin(_) | OpenFile::Stdout(_) | OpenFile::Stderr(_) => Ok(Self::inherit()),
261            OpenFile::File(f) => Ok(f.try_clone()?.into()),
262            OpenFile::PipeReader(r) => Ok(r.try_clone()?.into()),
263            OpenFile::PipeWriter(w) => Ok(w.try_clone()?.into()),
264            // Custom streams have no descriptor to hand to a child process.
265            OpenFile::Stream(_) => Ok(Self::null()),
266        }
267    }
268}
269
270impl std::io::Read for OpenFile {
271    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
272        match self {
273            Self::Stdin(f) => f.read(buf),
274            Self::Stdout(_) => Err(std::io::Error::other(
275                error::ErrorKind::OpenFileNotReadable("stdout"),
276            )),
277            Self::Stderr(_) => Err(std::io::Error::other(
278                error::ErrorKind::OpenFileNotReadable("stderr"),
279            )),
280            // The handle is shared behind an `Arc`; read through a shared reference (`&File`
281            // and `&PipeReader` both implement `Read`).
282            Self::File(f) => f.as_ref().read(buf),
283            Self::PipeReader(reader) => reader.as_ref().read(buf),
284            Self::PipeWriter(_) => Err(std::io::Error::other(
285                error::ErrorKind::OpenFileNotReadable("pipe writer"),
286            )),
287            Self::Stream(s) => s.read(buf),
288        }
289    }
290}
291
292impl std::io::Write for OpenFile {
293    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
294        match self {
295            Self::Stdin(_) => Err(std::io::Error::other(
296                error::ErrorKind::OpenFileNotWritable("stdin"),
297            )),
298            Self::Stdout(f) => f.write(buf),
299            Self::Stderr(f) => f.write(buf),
300            // The handle is shared behind an `Arc`; write through a shared reference (`&File`
301            // and `&PipeWriter` both implement `Write`).
302            Self::File(f) => f.as_ref().write(buf),
303            Self::PipeReader(_) => Err(std::io::Error::other(
304                error::ErrorKind::OpenFileNotWritable("pipe reader"),
305            )),
306            Self::PipeWriter(writer) => writer.as_ref().write(buf),
307            Self::Stream(s) => s.write(buf),
308        }
309    }
310
311    fn flush(&mut self) -> std::io::Result<()> {
312        match self {
313            Self::Stdin(_) => Ok(()),
314            Self::Stdout(f) => f.flush(),
315            Self::Stderr(f) => f.flush(),
316            Self::File(f) => f.as_ref().flush(),
317            Self::PipeReader(_) => Ok(()),
318            Self::PipeWriter(writer) => writer.as_ref().flush(),
319            Self::Stream(s) => s.flush(),
320        }
321    }
322}
323
324/// Tristate representing the an `OpenFile` entry in an `OpenFiles` structure.
325pub enum OpenFileEntry<'a> {
326    /// File descriptor is present and has a valid associated `OpenFile`.
327    Open(&'a OpenFile),
328    /// File descriptor is explicitly marked as not being mapped to any
329    /// `OpenFile`.
330    NotPresent,
331    /// File descriptor is not specified in any way; it may be provided by a
332    /// parent context of some kind.
333    NotSpecified,
334}
335
336/// Represents the open files in a shell context.
337#[derive(Clone, Default)]
338#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
339pub struct OpenFiles {
340    /// Maps shell file descriptors to open files.
341    files: HashMap<ShellFd, Option<OpenFile>>,
342}
343
344impl OpenFiles {
345    /// File descriptor used for standard input.
346    pub const STDIN_FD: ShellFd = 0;
347    /// File descriptor used for standard output.
348    pub const STDOUT_FD: ShellFd = 1;
349    /// File descriptor used for standard error.
350    pub const STDERR_FD: ShellFd = 2;
351
352    /// First file descriptor available for non-stdio files.
353    const FIRST_NON_STDIO_FD: ShellFd = 3;
354    /// Maximum file descriptor number allowed.
355    const MAX_FD: ShellFd = 1024;
356
357    /// Creates a new `OpenFiles` instance populated with stdin, stdout, and
358    /// stderr from the host environment.
359    pub(crate) fn new() -> Self {
360        Self {
361            files: HashMap::from([
362                (Self::STDIN_FD, Some(std::io::stdin().into())),
363                (Self::STDOUT_FD, Some(std::io::stdout().into())),
364                (Self::STDERR_FD, Some(std::io::stderr().into())),
365            ]),
366        }
367    }
368
369    /// Updates the open files from the provided iterator of (fd number,
370    /// `OpenFile`) pairs. Any existing entries for the provided file
371    /// descriptors will be overwritten.
372    ///
373    /// # Arguments
374    ///
375    /// * `files`: An iterator of (fd number, `OpenFile`) pairs to update the
376    ///   open files with.
377    pub fn update_from(&mut self, files: impl Iterator<Item = (ShellFd, OpenFile)>) {
378        for (fd, file) in files {
379            let _ = self.files.insert(fd, Some(file));
380        }
381    }
382
383    /// Retrieves the file backing standard input in this context.
384    pub fn try_stdin(&self) -> Option<&OpenFile> {
385        self.files.get(&Self::STDIN_FD).and_then(|f| f.as_ref())
386    }
387
388    /// Retrieves the file backing standard output in this context.
389    pub fn try_stdout(&self) -> Option<&OpenFile> {
390        self.files.get(&Self::STDOUT_FD).and_then(|f| f.as_ref())
391    }
392
393    /// Retrieves the file backing standard error in this context.
394    pub fn try_stderr(&self) -> Option<&OpenFile> {
395        self.files.get(&Self::STDERR_FD).and_then(|f| f.as_ref())
396    }
397
398    /// Tries to remove an open file by its file descriptor. If the file
399    /// descriptor is not used, `None` will be returned; otherwise, the
400    /// removed file will be returned.
401    ///
402    /// Arguments:
403    ///
404    /// * `fd`: The file descriptor to remove.
405    pub fn remove_fd(&mut self, fd: ShellFd) -> Option<OpenFile> {
406        self.files.insert(fd, None).and_then(|f| f)
407    }
408
409    /// Tries to lookup the `OpenFile` associated with a file descriptor.
410    /// Returns `None` if the file descriptor is not present.
411    ///
412    /// Arguments:
413    ///
414    /// * `fd`: The file descriptor to lookup.
415    pub fn try_fd(&self, fd: ShellFd) -> Option<&OpenFile> {
416        self.files.get(&fd).and_then(|f| f.as_ref())
417    }
418
419    /// Tries to lookup the `OpenFile` associated with a file descriptor.
420    /// Returns an `OpenFileEntry` representing the state of the file
421    /// descriptor.
422    ///
423    /// Arguments:
424    ///
425    /// * `fd`: The file descriptor to lookup.
426    pub fn fd_entry(&self, fd: ShellFd) -> OpenFileEntry<'_> {
427        self.files
428            .get(&fd)
429            .map_or(OpenFileEntry::NotSpecified, |opt_file| match opt_file {
430                Some(f) => OpenFileEntry::Open(f),
431                None => OpenFileEntry::NotPresent,
432            })
433    }
434
435    /// Checks if the given file descriptor is in use.
436    pub fn contains_fd(&self, fd: ShellFd) -> bool {
437        self.files.contains_key(&fd)
438    }
439
440    /// Associates the given file descriptor with the provided file. If the file
441    /// descriptor is already in use, the previous file will be returned;
442    /// otherwise, `None` will be returned.
443    ///
444    /// Arguments:
445    ///
446    /// * `fd`: The file descriptor to associate with the file.
447    /// * `file`: The file to associate with the file descriptor.
448    pub fn set_fd(&mut self, fd: ShellFd, file: OpenFile) -> Option<OpenFile> {
449        self.files.insert(fd, Some(file)).and_then(|f| f)
450    }
451
452    /// Iterates over all file descriptors.
453    pub fn iter_fds(&self) -> impl Iterator<Item = (ShellFd, &OpenFile)> {
454        self.files
455            .iter()
456            .filter_map(|(fd, file)| file.as_ref().map(|f| (*fd, f)))
457    }
458
459    /// Adds a new open file, returning the assigned file descriptor.
460    ///
461    /// # Arguments
462    ///
463    /// * `file`: The open file to add.
464    ///
465    /// # Errors
466    ///
467    /// Returns [`error::ErrorKind::TooManyOpenFiles`] if every descriptor up to
468    /// the maximum is already in use.
469    pub fn add(&mut self, file: OpenFile) -> Result<ShellFd, error::Error> {
470        // Start searching for free file descriptors after the standard ones.
471        let mut fd = Self::FIRST_NON_STDIO_FD;
472        while self.files.contains_key(&fd) {
473            if fd >= Self::MAX_FD {
474                return Err(error::ErrorKind::TooManyOpenFiles.into());
475            }
476
477            fd += 1;
478        }
479
480        self.files.insert(fd, Some(file));
481        Ok(fd)
482    }
483}
484
485impl<I> From<I> for OpenFiles
486where
487    I: Iterator<Item = (ShellFd, OpenFile)>,
488{
489    fn from(iter: I) -> Self {
490        let files = iter.map(|(fd, file)| (fd, Some(file))).collect();
491        Self { files }
492    }
493}