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