Skip to main content

linuxutils_system/
mountpoint.rs

1use linuxutils_common::man::ManContent;
2
3pub const MAN: ManContent = ManContent::empty();
4
5use clap::Parser;
6use rustix::fs::{FileType, lstat, major, minor, stat};
7use std::{
8    io::{self, BufRead},
9    path::{Path, PathBuf},
10    process::ExitCode,
11};
12
13const EXIT_NOTMOUNT: u8 = 32;
14
15#[derive(Parser)]
16#[command(
17    name = "mountpoint",
18    version,
19    about = "See if a directory or file is a mountpoint"
20)]
21pub struct Args {
22    /// Show the major/minor numbers of the device mounted on the given directory
23    #[arg(short = 'd', long = "fs-devno", conflicts_with = "devno")]
24    fs_devno: bool,
25
26    /// Be quiet - don't print anything
27    #[arg(short = 'q', long = "quiet")]
28    quiet: bool,
29
30    /// Do not follow symbolic link if it is the last element of the path
31    #[arg(long = "nofollow")]
32    nofollow: bool,
33
34    /// Show the major/minor numbers of the given block device
35    #[arg(short = 'x', long = "devno", conflicts_with_all = ["fs_devno", "quiet", "nofollow"])]
36    devno: bool,
37
38    /// Directory, file, or device to check
39    pub path: PathBuf,
40}
41
42pub fn run(args: Args) -> ExitCode {
43    if args.devno {
44        return show_devno(&args);
45    }
46
47    let st = match do_stat(&args.path, args.nofollow) {
48        Ok(s) => s,
49        Err(e) => {
50            if !args.quiet {
51                eprintln!("mountpoint: {}: {e}", args.path.display());
52            }
53            return ExitCode::from(1);
54        }
55    };
56
57    if args.fs_devno {
58        if let Some((maj, min)) = mountinfo_devno(&args.path) {
59            println!("{maj}:{min}");
60        } else {
61            println!("{}:{}", major(st.st_dev), minor(st.st_dev));
62        }
63        return ExitCode::SUCCESS;
64    }
65
66    let is_mount = is_mountpoint(&args.path);
67
68    if !args.quiet {
69        if is_mount {
70            println!("{} is a mountpoint", args.path.display());
71        } else {
72            println!("{} is not a mountpoint", args.path.display());
73        }
74    }
75
76    if is_mount {
77        ExitCode::SUCCESS
78    } else {
79        ExitCode::from(EXIT_NOTMOUNT)
80    }
81}
82
83fn do_stat(
84    path: &PathBuf,
85    nofollow: bool,
86) -> Result<rustix::fs::Stat, rustix::io::Errno> {
87    if nofollow { lstat(path) } else { stat(path) }
88}
89
90fn show_devno(args: &Args) -> ExitCode {
91    let st = match do_stat(&args.path, false) {
92        Ok(s) => s,
93        Err(e) => {
94            if !args.quiet {
95                eprintln!("mountpoint: {}: {e}", args.path.display());
96            }
97            return ExitCode::from(1);
98        }
99    };
100
101    // Check if it's a block device.
102    if !FileType::from_raw_mode(st.st_mode).is_block_device() {
103        if !args.quiet {
104            eprintln!(
105                "mountpoint: {}: not a block device",
106                args.path.display()
107            );
108        }
109        return ExitCode::from(EXIT_NOTMOUNT);
110    }
111
112    println!("{}:{}", major(st.st_rdev), minor(st.st_rdev));
113    ExitCode::SUCCESS
114}
115
116/// Check if a path is a mountpoint by looking it up in /proc/self/mountinfo.
117fn is_mountpoint(path: &PathBuf) -> bool {
118    if find_in_mountinfo(path).is_some() {
119        return true;
120    }
121    // Fallback: compare device IDs of path and its parent.
122    match stat(path) {
123        Ok(st) => is_mountpoint_by_stat(&st, path),
124        Err(_) => false,
125    }
126}
127
128/// Look up a path in /proc/self/mountinfo and return its major:minor if found.
129fn find_in_mountinfo(path: &PathBuf) -> Option<(u32, u32)> {
130    let canonical = std::fs::canonicalize(path).ok()?;
131    let canonical = canonical.to_string_lossy();
132
133    let file = std::fs::File::open("/proc/self/mountinfo").ok()?;
134    let reader = io::BufReader::new(file);
135
136    for line in reader.lines() {
137        let Ok(line) = line else { continue };
138        // mountinfo format: ID PARENT_ID MAJOR:MINOR ROOT MOUNT_POINT ...
139        let mut fields = line.split_whitespace();
140        let Some(_id) = fields.next() else { continue };
141        let Some(_parent) = fields.next() else {
142            continue;
143        };
144        let Some(devno) = fields.next() else { continue };
145        let Some(_root) = fields.next() else { continue };
146        let Some(mount_point) = fields.next() else {
147            continue;
148        };
149
150        let decoded = unescape_mountinfo(mount_point);
151        if *canonical == decoded {
152            let (maj_s, min_s) = devno.split_once(':')?;
153            let maj = maj_s.parse().ok()?;
154            let min = min_s.parse().ok()?;
155            return Some((maj, min));
156        }
157    }
158
159    None
160}
161
162/// Look up the device number from mountinfo for a given path.
163fn mountinfo_devno(path: &PathBuf) -> Option<(u32, u32)> {
164    find_in_mountinfo(path)
165}
166
167/// Fallback: a path is a mountpoint if its device ID differs from its parent's.
168fn is_mountpoint_by_stat(st: &rustix::fs::Stat, path: &Path) -> bool {
169    let parent = path.join("..");
170    match stat(&parent) {
171        Ok(parent_st) => st.st_dev != parent_st.st_dev,
172        Err(_) => false,
173    }
174}
175
176/// Unescape octal sequences like \040 in mountinfo paths.
177fn unescape_mountinfo(s: &str) -> String {
178    let mut result = String::with_capacity(s.len());
179    let mut chars = s.chars();
180    while let Some(c) = chars.next() {
181        if c == '\\' {
182            let oct: String = chars.by_ref().take(3).collect();
183            if let Ok(byte) = u8::from_str_radix(&oct, 8) {
184                result.push(byte as char);
185            } else {
186                result.push('\\');
187                result.push_str(&oct);
188            }
189        } else {
190            result.push(c);
191        }
192    }
193    result
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn unescape_simple() {
202        assert_eq!(unescape_mountinfo("/mnt/my\\040drive"), "/mnt/my drive");
203    }
204
205    #[test]
206    fn unescape_no_escapes() {
207        assert_eq!(unescape_mountinfo("/mnt/data"), "/mnt/data");
208    }
209
210    #[test]
211    fn root_is_mountpoint() {
212        assert!(is_mountpoint(&PathBuf::from("/")));
213    }
214}