Skip to main content

shrub_core/shell/
fs.rs

1//! Filesystem interaction in the shell.
2
3use std::path::{Path, PathBuf};
4
5use normalize_path::NormalizePath as _;
6
7use crate::env::{EnvironmentLookup, EnvironmentScope};
8use crate::sys::users;
9use crate::{ExecutionParameters, ShellFd, error, openfiles, pathsearch, variables};
10
11impl<SE: crate::extensions::ShellExtensions> crate::Shell<SE> {
12    /// Sets the shell's current working directory to the given path.
13    ///
14    /// # Arguments
15    ///
16    /// * `target_dir` - The path to set as the working directory.
17    pub fn set_working_dir(&mut self, target_dir: impl AsRef<Path>) -> Result<(), error::Error> {
18        let abs_path = self.absolute_path(target_dir.as_ref());
19
20        match std::fs::metadata(&abs_path) {
21            Ok(m) => {
22                if !m.is_dir() {
23                    return Err(error::ErrorKind::NotADirectory(abs_path).into());
24                }
25            }
26            Err(e) => {
27                return Err(e.into());
28            }
29        }
30
31        // Normalize the path (but don't canonicalize it).
32        let cleaned_path = abs_path.normalize();
33
34        let pwd = cleaned_path.to_string_lossy().to_string();
35
36        self.env.update_or_add(
37            "PWD",
38            variables::ShellValueLiteral::Scalar(pwd),
39            |_| Ok(()),
40            EnvironmentLookup::Anywhere,
41            EnvironmentScope::Global,
42        )?;
43        let oldpwd = std::mem::replace(self.working_dir_mut(), cleaned_path);
44
45        self.env.update_or_add(
46            "OLDPWD",
47            variables::ShellValueLiteral::Scalar(oldpwd.to_string_lossy().to_string()),
48            |_| Ok(()),
49            EnvironmentLookup::Anywhere,
50            EnvironmentScope::Global,
51        )?;
52
53        Ok(())
54    }
55
56    /// Tilde-shortens the given string, replacing the user's home directory
57    /// with a tilde.
58    ///
59    /// # Arguments
60    ///
61    /// * `s` - The string to shorten.
62    pub fn tilde_shorten(&self, s: String) -> String {
63        if let Some(home_dir) = self.home_dir()
64            && let Some(stripped) = s.strip_prefix(home_dir.to_string_lossy().as_ref())
65        {
66            return format!("~{stripped}");
67        }
68        s
69    }
70
71    /// Returns the shell's current home directory, if available.
72    pub(crate) fn home_dir(&self) -> Option<PathBuf> {
73        if let Some(home) = self.env.get_str("HOME", self) {
74            Some(PathBuf::from(home.to_string()))
75        } else {
76            // HOME isn't set, so let's sort it out ourselves.
77            users::get_current_user_home_dir()
78        }
79    }
80
81    /// Finds executables with the given name in the shell's current PATH,
82    /// yielding each match in search order.
83    ///
84    /// # Arguments
85    ///
86    /// * `filename` - The name of the executable to look for.
87    pub fn find_executables_in_path<'a>(
88        &'a self,
89        filename: &'a str,
90    ) -> impl Iterator<Item = PathBuf> + 'a {
91        let path_var = self.env.get_str("PATH", self).unwrap_or_default();
92        let paths = crate::sys::fs::split_paths(path_var.as_ref());
93
94        pathsearch::search_for_executable(paths, filename)
95    }
96
97    /// Finds executables in the shell's current default PATH, with filenames
98    /// matching the given prefix.
99    ///
100    /// # Arguments
101    ///
102    /// * `filename_prefix` - The prefix to match against executable filenames.
103    pub fn find_executables_in_path_with_prefix(
104        &self,
105        filename_prefix: &str,
106        case_insensitive: bool,
107    ) -> impl Iterator<Item = PathBuf> {
108        let path_var = self.env.get_str("PATH", self).unwrap_or_default();
109        let paths = crate::sys::fs::split_paths(path_var.as_ref());
110
111        pathsearch::search_for_executable_with_prefix(paths, filename_prefix, case_insensitive)
112    }
113
114    /// Determines whether the given filename is the name of an executable in
115    /// one of the directories in the shell's current PATH. If found,
116    /// returns the path.
117    ///
118    /// # Arguments
119    ///
120    /// * `candidate_name` - The name of the file to look for.
121    pub fn find_first_executable_in_path<S: AsRef<str>>(
122        &self,
123        candidate_name: S,
124    ) -> Option<PathBuf> {
125        self.find_executables_in_path(candidate_name.as_ref())
126            .next()
127    }
128
129    /// Uses the shell's hash-based path cache to check whether the given
130    /// filename is the name of an executable in one of the directories in
131    /// the shell's current PATH. If found, ensures the path is in the cache
132    /// and returns it.
133    ///
134    /// # Arguments
135    ///
136    /// * `candidate_name` - The name of the file to look for.
137    pub fn find_first_executable_in_path_using_cache<S: AsRef<str>>(
138        &mut self,
139        candidate_name: S,
140    ) -> Option<PathBuf>
141    where
142        String: From<S>,
143    {
144        if let Some(cached_path) = self.program_location_cache.get(&candidate_name) {
145            Some(cached_path)
146        } else if let Some(found_path) = self.find_first_executable_in_path(&candidate_name) {
147            self.program_location_cache
148                .set(candidate_name, found_path.clone());
149            Some(found_path)
150        } else {
151            None
152        }
153    }
154
155    /// Resolves a command name by searching the shell's current PATH.
156    ///
157    /// Unlike [`Self::find_first_executable_in_path`], a non-executable entry
158    /// in the PATH resolves as the command; the shell reports it as the
159    /// command and then fails to run it.
160    /// See [`pathsearch::resolve_command`].
161    ///
162    /// # Arguments
163    ///
164    /// * `candidate_name` - The name of the command to resolve.
165    pub fn resolve_command_in_path<S: AsRef<str>>(&self, candidate_name: S) -> Option<PathBuf> {
166        let path_var = self.env.get_str("PATH", self).unwrap_or_default();
167        let paths = crate::sys::fs::split_paths(path_var.as_ref());
168        pathsearch::resolve_command(paths, candidate_name.as_ref())
169    }
170
171    /// Like [`Self::resolve_command_in_path`], but consults the shell's
172    /// hash-based path cache first and caches whatever a search turns up.
173    ///
174    /// # Arguments
175    ///
176    /// * `candidate_name` - The name of the command to resolve.
177    pub fn resolve_command_in_path_using_cache<S: AsRef<str>>(
178        &mut self,
179        candidate_name: S,
180    ) -> Option<PathBuf>
181    where
182        String: From<S>,
183    {
184        if let Some(cached_path) = self.program_location_cache.get(&candidate_name) {
185            return Some(cached_path);
186        }
187
188        let found_path = self.resolve_command_in_path(candidate_name.as_ref())?;
189        self.program_location_cache
190            .set(candidate_name, found_path.clone());
191
192        Some(found_path)
193    }
194
195    /// Gets the absolute form of the given path.
196    ///
197    /// # Arguments
198    ///
199    /// * `path` - The path to get the absolute form of.
200    pub fn absolute_path(&self, path: impl AsRef<Path>) -> PathBuf {
201        let path = path.as_ref();
202        if path.as_os_str().is_empty() || path.is_absolute() {
203            path.to_owned()
204        } else {
205            self.working_dir().join(path)
206        }
207    }
208
209    /// Opens the given file, using the context of this shell and the provided
210    /// execution parameters.
211    ///
212    /// # Arguments
213    ///
214    /// * `options` - The options to use opening the file.
215    /// * `path` - The path to the file to open; may be relative to the shell's
216    ///   working directory.
217    /// * `params` - Execution parameters.
218    pub(crate) fn open_file(
219        &self,
220        options: &std::fs::OpenOptions,
221        path: impl AsRef<Path>,
222        params: &ExecutionParameters,
223    ) -> Result<openfiles::OpenFile, std::io::Error> {
224        // Give platform-specific code a chance to handle special files
225        // (e.g. /dev/null on Windows, which needs to open NUL instead).
226        // This is checked before absolute_path so that paths like /dev/null
227        // are intercepted on platforms where they aren't valid native paths.
228        if let Some(result) = crate::sys::fs::try_open_special_file(path.as_ref()) {
229            return result.map(openfiles::OpenFile::from);
230        }
231
232        let path_to_open = self.absolute_path(path.as_ref());
233
234        // See if this is a reference to a file descriptor. These paths should
235        // reflect the shell's current execution fds, which can differ from the
236        // host process fds after redirections like here-docs.
237        if let Some(fd_num) = shell_fd_path_to_fd(&path_to_open)
238            && let Some(open_file) = params.try_fd(self, fd_num)
239        {
240            return Ok(open_file);
241        }
242
243        Ok(options.open(path_to_open)?.into())
244    }
245
246    /// Replaces the shell's currently configured open files with the given set.
247    /// Typically only used by exec-like builtins.
248    ///
249    /// # Arguments
250    ///
251    /// * `open_files` - The new set of open files to use.
252    pub fn replace_open_files(
253        &mut self,
254        open_fds: impl Iterator<Item = (ShellFd, openfiles::OpenFile)>,
255    ) {
256        self.open_files = openfiles::OpenFiles::from(open_fds);
257    }
258
259    pub(crate) const fn persistent_open_files(&self) -> &openfiles::OpenFiles {
260        &self.open_files
261    }
262}
263
264fn shell_fd_path_to_fd(path: &Path) -> Option<ShellFd> {
265    match path.to_str()? {
266        "/dev/stdin" => return Some(openfiles::OpenFiles::STDIN_FD),
267        "/dev/stdout" => return Some(openfiles::OpenFiles::STDOUT_FD),
268        "/dev/stderr" => return Some(openfiles::OpenFiles::STDERR_FD),
269        _ => {}
270    }
271
272    if let Some(parent) = path.parent()
273        && parent == Path::new("/dev/fd")
274        && let Some(filename) = path.file_name()
275    {
276        filename.to_string_lossy().parse::<ShellFd>().ok()
277    } else {
278        None
279    }
280}