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