Skip to main content

brush_core/sys/unix/
fs.rs

1//! Filesystem utilities.
2
3use std::os::unix::ffi::OsStringExt;
4use std::os::unix::fs::FileTypeExt;
5use std::path::{Path, PathBuf};
6
7use crate::error;
8
9pub use std::os::unix::fs::MetadataExt;
10
11#[cfg(target_os = "android")]
12// _PATH_DEFPATH in https://android.googlesource.com/platform/bionic/+/refs/heads/main/libc/include/paths.h
13const ANDROID_DEFPATH: &str = "/product/bin:/apex/com.android.runtime/bin:/apex/com.android.art/bin:/apex/com.android.virt/bin:/system_ext/bin:/system/bin:/system/xbin:/odm/bin:/vendor/bin:/vendor/xbin";
14
15impl crate::sys::fs::PathExt for Path {
16    fn readable(&self) -> bool {
17        nix::unistd::access(self, nix::unistd::AccessFlags::R_OK).is_ok()
18    }
19
20    fn writable(&self) -> bool {
21        nix::unistd::access(self, nix::unistd::AccessFlags::W_OK).is_ok()
22    }
23
24    fn executable(&self) -> bool {
25        nix::unistd::access(self, nix::unistd::AccessFlags::X_OK).is_ok()
26    }
27
28    fn exists_and_is_block_device(&self) -> bool {
29        try_get_file_type(self).is_some_and(|ft| ft.is_block_device())
30    }
31
32    fn exists_and_is_char_device(&self) -> bool {
33        try_get_file_type(self).is_some_and(|ft| ft.is_char_device())
34    }
35
36    fn exists_and_is_fifo(&self) -> bool {
37        try_get_file_type(self).is_some_and(|ft: std::fs::FileType| ft.is_fifo())
38    }
39
40    fn exists_and_is_socket(&self) -> bool {
41        try_get_file_type(self).is_some_and(|ft| ft.is_socket())
42    }
43
44    fn exists_and_is_setgid(&self) -> bool {
45        const S_ISGID: u32 = 0o2000;
46        let file_mode = try_get_file_mode(self);
47        file_mode.is_some_and(|mode| mode & S_ISGID != 0)
48    }
49
50    fn exists_and_is_setuid(&self) -> bool {
51        const S_ISUID: u32 = 0o4000;
52        let file_mode = try_get_file_mode(self);
53        file_mode.is_some_and(|mode| mode & S_ISUID != 0)
54    }
55
56    fn exists_and_is_sticky_bit(&self) -> bool {
57        const S_ISVTX: u32 = 0o1000;
58        let file_mode = try_get_file_mode(self);
59        file_mode.is_some_and(|mode| mode & S_ISVTX != 0)
60    }
61
62    fn get_device_and_inode(&self) -> Result<(u64, u64), crate::error::Error> {
63        let metadata = self.metadata()?;
64        Ok((metadata.dev(), metadata.ino()))
65    }
66}
67
68fn try_get_file_type(path: &Path) -> Option<std::fs::FileType> {
69    path.metadata().map(|metadata| metadata.file_type()).ok()
70}
71
72fn try_get_file_mode(path: &Path) -> Option<u32> {
73    path.metadata().map(|metadata| metadata.mode()).ok()
74}
75
76/// Splits a platform-specific PATH-like value into individual paths.
77///
78/// On Unix, this delegates to [`std::env::split_paths`].
79pub fn split_paths<T: AsRef<std::ffi::OsStr> + ?Sized>(s: &T) -> std::env::SplitPaths<'_> {
80    std::env::split_paths(s)
81}
82
83pub(crate) fn get_default_executable_search_paths() -> Vec<PathBuf> {
84    #[cfg(target_os = "android")]
85    {
86        std::env::split_paths(ANDROID_DEFPATH).collect()
87    }
88    #[cfg(not(target_os = "android"))]
89    {
90        // standard hard-coded defaults for executable search path
91        vec![
92            "/usr/local/sbin".into(),
93            "/usr/local/bin".into(),
94            "/usr/sbin".into(),
95            "/usr/bin".into(),
96            "/sbin".into(),
97            "/bin".into(),
98        ]
99    }
100}
101
102/// Retrieves the platform-specific set of paths that should contain standard system
103/// utilities. Used by `command -p`, for example.
104pub fn get_default_standard_utils_paths() -> Vec<PathBuf> {
105    //
106    // Try to call confstr(_CS_PATH). If that fails, can't find a string value, or
107    // finds an empty string, then we'll fall back to hard-coded defaults.
108    //
109
110    if let Ok(Some(cs_path)) = confstr_cs_path()
111        && !cs_path.as_os_str().is_empty()
112    {
113        return split_paths(&cs_path).collect();
114    }
115
116    #[cfg(target_os = "android")]
117    {
118        std::env::split_paths(ANDROID_DEFPATH).collect()
119    }
120    #[cfg(not(target_os = "android"))]
121    {
122        // standard hard-coded defaults
123        vec![
124            "/bin".into(),
125            "/usr/bin".into(),
126            "/sbin".into(),
127            "/usr/sbin".into(),
128            "/etc".into(),
129            "/usr/etc".into(),
130        ]
131    }
132}
133
134#[allow(clippy::unnecessary_wraps)]
135fn confstr_cs_path() -> Result<Option<PathBuf>, std::io::Error> {
136    #[cfg(target_os = "android")]
137    {
138        Ok(Some(PathBuf::from(ANDROID_DEFPATH)))
139    }
140    #[cfg(not(target_os = "android"))]
141    {
142        let value = confstr(nix::libc::_CS_PATH)?;
143
144        if let Some(value) = value {
145            let value_str = PathBuf::from(value);
146            Ok(Some(value_str))
147        } else {
148            Ok(None)
149        }
150    }
151}
152
153/// A wrapper for [`nix::libc::confstr`]. Returns a value for the default PATH variable which
154/// indicates where all the POSIX.2 standard utilities can be found.
155///
156/// N.B. We would strongly prefer to use a safe API exposed (in an idiomatic way) by nix
157/// or similar. Until that exists, we accept the need to make the unsafe call directly.
158#[cfg(not(target_os = "android"))]
159fn confstr(name: nix::libc::c_int) -> Result<Option<std::ffi::OsString>, std::io::Error> {
160    // SAFETY:
161    // Calling `confstr` with a null pointer and size 0 is a documented way to query
162    // the required size of the buffer to hold the value associated with `name`. It
163    // should not end up causing any undefined behavior.
164    let required_size = unsafe { nix::libc::confstr(name, std::ptr::null_mut(), 0) };
165
166    // When confstr returns 0, it either means there's no value associated with _CS_PATH, or
167    // _CS_PATH is considered invalid (and not present) on this platform. In both cases, we
168    // treat it as a non-existent value and return None.
169    if required_size == 0 {
170        return Ok(None);
171    }
172
173    let mut buffer = Vec::<u8>::with_capacity(required_size);
174
175    // SAFETY:
176    // We are calling `confstr` with a valid pointer and size that we obtained from the
177    // allocated buffer. Writing `c_char` (i8 or u8 depending on the platform) into
178    // `Vec<u8>` is fine, as i8 and u8 have compatible representations, and Rust does
179    // not support platforms where `c_char` is not 8-bit wide.
180    let final_size =
181        unsafe { nix::libc::confstr(name, buffer.as_mut_ptr().cast(), buffer.capacity()) };
182
183    if final_size == 0 {
184        return Err(std::io::Error::last_os_error());
185    }
186
187    // Per the docs on `confstr`, it *may* return a size larger than the provided buffer.
188    // In our usage we wouldn't expect to see this, as we've first queried the required size.
189    // However, we defensively check for this case and return an error if it happens.
190    if final_size > buffer.capacity() {
191        return Err(std::io::Error::other(
192            "confstr needed more space than advertised",
193        ));
194    }
195
196    // SAFETY:
197    // We are trusting `confstr` to have written exactly `final_size` bytes into the buffer.
198    // We have checked above that it didn't return a value *larger* than the capacity of
199    // the buffer, and also checked for known error cases. Note that the returned length
200    // should include the null terminator.
201    unsafe { buffer.set_len(final_size) };
202
203    // The last byte is a null terminator. We assert that it is.
204    if !matches!(buffer.pop(), Some(0)) {
205        return Err(std::io::Error::other(
206            "confstr did not null-terminate the returned string",
207        ));
208    }
209
210    Ok(Some(std::ffi::OsString::from_vec(buffer)))
211}
212
213/// Opens a null file that will discard all I/O.
214pub fn open_null_file() -> Result<std::fs::File, error::Error> {
215    let f = std::fs::File::options()
216        .read(true)
217        .write(true)
218        .open("/dev/null")?;
219
220    Ok(f)
221}
222
223/// Gives the platform an opportunity to handle a special file path (e.g. `/dev/null`).
224pub const fn try_open_special_file(_path: &Path) -> Option<Result<std::fs::File, std::io::Error>> {
225    None
226}
227
228/// Returns the path to the system-wide shell profile script.
229pub fn get_system_profile_path() -> Option<&'static Path> {
230    Some(Path::new("/etc/profile"))
231}
232
233/// Returns the path to the system-wide shell rc script.
234pub fn get_system_rc_path() -> Option<&'static Path> {
235    Some(Path::new("/etc/bash.bashrc"))
236}
237
238/// Returns true if the string contains a path separator character.
239///
240/// On Unix, only `/` is considered a path separator.
241pub fn contains_path_separator(s: &str) -> bool {
242    s.contains('/')
243}
244
245/// Returns true if the string ends with a path separator character.
246///
247/// On Unix, only `/` is considered a path separator.
248pub fn ends_with_path_separator(s: &str) -> bool {
249    s.ends_with('/')
250}
251
252/// Returns the string with a trailing path separator removed, if present.
253///
254/// On Unix, only `/` is considered a path separator.
255pub fn strip_path_separator_suffix(s: &str) -> &str {
256    s.strip_suffix('/').unwrap_or(s)
257}
258
259/// Returns the platform default for case-insensitive pathname expansion.
260///
261/// On Unix, filesystems are typically case-sensitive, so this returns `false`.
262pub const fn default_case_insensitive_path_expansion() -> bool {
263    false
264}
265
266/// Finds the byte index of the last path separator in the string.
267///
268/// On Unix, only `/` is considered a path separator.
269pub fn rfind_path_separator(s: &str) -> Option<usize> {
270    s.rfind('/')
271}
272
273/// Splits a string on path separator characters, returning an iterator of components.
274///
275/// On Unix, only `/` is used as a separator.
276pub fn split_path_for_pattern(s: &str) -> impl Iterator<Item = &str> {
277    s.split('/')
278}
279
280/// Returns the root path for an absolute pattern, if the first component indicates one.
281///
282/// On Unix, an empty first component (from splitting a path like `/foo`) indicates
283/// an absolute path rooted at `/`.
284pub fn pattern_path_root(first_component: &str) -> Option<PathBuf> {
285    if first_component.is_empty() {
286        Some(PathBuf::from("/"))
287    } else {
288        None
289    }
290}
291
292/// Pushes a component onto a path for pattern expansion.
293///
294/// On Unix, this delegates directly to `PathBuf::push`.
295pub fn push_path_for_pattern(path: &mut std::path::PathBuf, component: &str) {
296    path.push(component);
297}
298
299/// Normalizes path separators for shell output.
300///
301/// On Unix, this is a no-op since paths already use `/`.
302pub const fn normalize_path_separators(s: &str) -> std::borrow::Cow<'_, str> {
303    std::borrow::Cow::Borrowed(s)
304}
305
306/// Resolves an owned path to the actual on-disk executable file, if any.
307///
308/// On Unix this is a straight passthrough: if the path is executable, the
309/// path is returned unchanged (no clone). This keeps `pathsearch::next`
310/// allocation-free on the happy path.
311///
312/// On Windows this function may append a `PATHEXT` extension and return a
313/// possibly-different `PathBuf`.
314pub fn resolve_executable(path: PathBuf) -> Option<PathBuf> {
315    use crate::sys::fs::PathExt;
316    if path.as_path().executable() {
317        Some(path)
318    } else {
319        None
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn path_separator_helpers() {
329        assert!(contains_path_separator("foo/bar"));
330        assert!(!contains_path_separator("foobar"));
331        // Backslashes are not separators on Unix.
332        assert!(!contains_path_separator(r"foo\bar"));
333
334        assert!(ends_with_path_separator("foo/"));
335        assert!(!ends_with_path_separator("foo"));
336        assert!(!ends_with_path_separator(r"foo\"));
337
338        assert_eq!(strip_path_separator_suffix("foo/"), "foo");
339        assert_eq!(strip_path_separator_suffix("foo"), "foo");
340        assert_eq!(strip_path_separator_suffix(r"foo\"), r"foo\");
341
342        assert_eq!(rfind_path_separator("a/b/c"), Some(3));
343        assert_eq!(rfind_path_separator("abc"), None);
344    }
345
346    #[test]
347    fn split_path_for_pattern_basic() {
348        let parts: Vec<_> = split_path_for_pattern("a/b/c").collect();
349        assert_eq!(parts, vec!["a", "b", "c"]);
350
351        let parts: Vec<_> = split_path_for_pattern("/a/b").collect();
352        assert_eq!(parts, vec!["", "a", "b"]);
353
354        // Backslashes are not split on Unix.
355        let parts: Vec<_> = split_path_for_pattern(r"a\b").collect();
356        assert_eq!(parts, vec![r"a\b"]);
357    }
358
359    #[test]
360    fn pattern_path_root_absolute() {
361        assert_eq!(pattern_path_root(""), Some(PathBuf::from("/")));
362    }
363
364    #[test]
365    fn pattern_path_root_relative() {
366        assert_eq!(pattern_path_root("foo"), None);
367        // Drive-letter syntax is not recognized on Unix.
368        assert_eq!(pattern_path_root("c:"), None);
369    }
370
371    #[test]
372    fn push_path_for_pattern_appends_child() {
373        let mut p = PathBuf::from("/home/reuben");
374        push_path_for_pattern(&mut p, "foo");
375        assert_eq!(p, PathBuf::from("/home/reuben/foo"));
376    }
377
378    #[test]
379    fn normalize_path_separators_is_noop() {
380        use std::borrow::Cow;
381        assert!(matches!(
382            normalize_path_separators("/foo/bar"),
383            Cow::Borrowed("/foo/bar")
384        ));
385    }
386
387    #[test]
388    fn default_case_insensitive_is_false() {
389        assert!(!default_case_insensitive_path_expansion());
390    }
391
392    #[test]
393    fn resolve_executable_returns_input_unchanged() {
394        // /bin/sh exists and is executable on every supported Unix host.
395        let path = PathBuf::from("/bin/sh");
396        let resolved = resolve_executable(path.clone());
397        assert_eq!(resolved.as_deref(), Some(path.as_path()));
398    }
399
400    #[test]
401    fn resolve_executable_returns_none_for_nonexistent() {
402        let path = PathBuf::from("/this/path/should/not/exist/brush-test");
403        assert!(resolve_executable(path).is_none());
404    }
405
406    #[test]
407    fn resolve_executable_returns_none_for_non_executable() {
408        // /etc/hostname (or similar) is a regular file but not executable.
409        // Use /etc/passwd which is universally present and not executable.
410        let path = PathBuf::from("/etc/passwd");
411        assert!(resolve_executable(path).is_none());
412    }
413}