Skip to main content

shrub_core/sys/unix/
fs.rs

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