Skip to main content

git_policy_cli/
fs_mount_options.rs

1//! What:
2//!  The mount of a path read from `/proc/self/mountinfo`: its mount point, filesystem type, source and both option
3//!  lists, so a caller can ask whether `user_subvol_rm_allowed` is active.
4//! Why:
5//!  The option decides whether `BTRFS_IOC_SNAP_DESTROY` works for an unprivileged user (the kernel checks the
6//!  mount's super options), and the doctor must tell a person which command turns it on. Reading the table in the
7//!  process spawns nothing. `filesystem_identity_mount.rs` parses the same table but keeps only the fields it needs
8//!  and declines the whole table on a shape it does not know; this reader keeps the option fields and skips a line it
9//!  cannot read, because a missed line can only hide a mount the path is not under.
10//!  The line grammar is `proc_pid_mountinfo(5)`: `id parent major:minor root mount-point options [optional...] - fstype
11//!  source super-options`, with space, tab, newline and backslash escaped as `\ooo` octal in the path fields.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! const facts = parseMountinfo(await readFile('/proc/self/mountinfo')).filter(m => path.startsWith(m.mountPoint)).at(-1);
16//! const allowed = facts.superOptions.includes('user_subvol_rm_allowed');
17//! ```
18
19use std::ffi::OsStr;
20use std::os::unix::ffi::OsStrExt;
21use std::path::{Path, PathBuf};
22
23/// Where the kernel lists the mounts of this process's mount namespace.
24pub const MOUNTINFO_PATH: &str = "/proc/self/mountinfo";
25
26/// What:
27///  One mount.
28///  `Vec<String>` is an owned list of owned UTF-8 text (sibling `&[&str]` is a borrowed view).
29/// Why:
30///   The ladder and the doctor need the mount point (to name the `mount` command), the type (btrfs or overlay) and
31///  the options (to know whether subvolume removal is allowed).
32///
33/// In TS you'd write (pseudocode):
34/// ```ts
35/// type MountFacts = { mountPoint: string; filesystemType: string; source: string; mountOptions: string[]; superOptions: string[] };
36/// ```
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct MountFacts {
39    /// The kernel's id of the mount (first field).
40    pub mount_id: u64,
41    /// The id of the mount this one is mounted in (second field); a mount hidden by a later mount at its place keeps
42    ///  the id of the mount that was there before.
43    pub parent_id: u64,
44    /// The mount point as seen from this process's root, escapes decoded.
45    pub mount_point: PathBuf,
46    /// The filesystem type, such as `btrfs` or `overlay`.
47    pub filesystem_type: String,
48    /// The mount source, such as `/dev/dm-1`, escapes decoded.
49    pub source: String,
50    /// The per-mount options (sixth field), such as `rw` and `relatime`.
51    pub mount_options: Vec<String>,
52    /// The per-superblock options (last field), such as `compress=zstd:1` and `user_subvol_rm_allowed`.
53    pub super_options: Vec<String>,
54}
55
56impl MountFacts {
57    /// What:
58    ///  Whether an option is active on this mount, in either option list.
59    /// Why:
60    ///   The kernel prints btrfs mount options such as `user_subvol_rm_allowed` among the super options, but a
61    ///  per-mount option such as `ro` among the mount options; the caller should not need to know which list.
62    ///
63    /// In TS you'd write (pseudocode):
64    /// ```ts
65    /// const hasOption = (m: MountFacts, name: string) => m.mountOptions.includes(name) || m.superOptions.includes(name);
66    /// ```
67    pub fn has_option(&self, name: &str) -> bool {
68        return self.mount_options.iter().any(|option| { return option == name; })
69            || self.super_options.iter().any(|option| { return option == name; });
70    }
71}
72
73/// What:
74///  Split a comma-separated option field into its options.
75/// Why:
76///   Options are separated by commas; an empty field has none.
77///
78/// In TS you'd write (pseudocode):
79/// ```ts
80/// const options = (field: string) => field === '' ? [] : field.split(',');
81/// ```
82fn split_options(field: &[u8]) -> Vec<String> {
83    if field.is_empty() {
84        return Vec::new();
85    }
86    return field
87        .split(|byte: &u8| { return *byte == b','; })
88        .map(|option: &[u8]| { return String::from_utf8_lossy(option).into_owned(); })
89        .collect();
90}
91
92/// What:
93///  Parse one mountinfo line, or nothing when it does not have the documented shape.
94/// Why:
95///   The fields are separated by single spaces, the optional fields end at a lone `-`, and three fields follow it.
96///
97/// In TS you'd write (pseudocode):
98/// ```ts
99/// function parseLine(line: Uint8Array): MountFacts | undefined;
100/// ```
101fn parse_line(line: &[u8]) -> Option<MountFacts> {
102    let fields: Vec<&[u8]> = line.split(|byte: &u8| { return *byte == b' '; }).collect();
103    let separator: usize = fields.iter().position(|field: &&[u8]| { return *field == b"-"; })?;
104    if separator < 6 || fields.len() < separator + 4 {
105        return None;
106    }
107    return Some(MountFacts {
108        mount_id: String::from_utf8_lossy(fields[0]).parse::<u64>().ok()?,
109        parent_id: String::from_utf8_lossy(fields[1]).parse::<u64>().ok()?,
110        mount_point: PathBuf::from(OsStr::from_bytes(unmangle_octal(fields[4]).as_slice())),
111        filesystem_type: String::from_utf8_lossy(fields[separator + 1]).into_owned(),
112        source: String::from_utf8_lossy(unmangle_octal(fields[separator + 2]).as_slice()).into_owned(),
113        mount_options: split_options(fields[5]),
114        super_options: split_options(fields[separator + 3]),
115    });
116}
117
118/// What:
119///  Every readable mount of the table text, in file order.
120/// Why:
121///   File order is mount order, so when two mounts share a mount point the later one is the visible one.
122///
123/// In TS you'd write (pseudocode):
124/// ```ts
125/// const parseMountTable = (text: Uint8Array): MountFacts[] => lines(text).map(parseLine).filter(Boolean);
126/// ```
127pub fn parse_mount_table(text: &[u8]) -> Vec<MountFacts> {
128    return text
129        .split(|byte: &u8| { return *byte == b'\n'; })
130        .filter_map(parse_line)
131        .collect();
132}
133
134/// What:
135///  The mount whose mount point is the longest ancestor of the path (or the path itself), the later one when two share a
136///  mount point; used only when the table's ids do not link up.
137/// Why:
138///   A table read from a mount namespace that hides its root, or cut by a test, has no chain to follow.
139///
140/// In TS you'd write (pseudocode):
141/// ```ts
142/// const longest = (table: MountFacts[], path: string) => table.filter(m => isAncestor(m.mountPoint, path)).sort(byLength).at(-1);
143/// ```
144fn longest_mount_holding<'table>(table: &'table [MountFacts], path: &Path) -> Option<&'table MountFacts> {
145    let mut best: Option<&MountFacts> = None;
146    for candidate in table {
147        if !path.starts_with(candidate.mount_point.as_path()) {
148            continue;
149        }
150        let longer_or_equal: bool = match best {
151            Some(current) => candidate.mount_point.as_os_str().len() >= current.mount_point.as_os_str().len(),
152            None => true,
153        };
154        if longer_or_equal {
155            best = Some(candidate);
156        }
157    }
158    return best;
159}
160
161/// What:
162///  The mount that holds `path`: found by walking from the root mount down the path, taking at each directory the
163///  topmost mount made there inside the mount reached so far.
164/// Why:
165///   Walking the way path resolution does keeps a mount that a later mount covers (the covering mount's parent is the
166///  same, and the walk goes through the covering one) from being taken for the holder just because its mount point is
167///  longer. A mount whose parent id is not in the table is taken as a root of the walk; when the walk reaches no mount
168///  at all, the longest mount point decides. Comparison is by whole path components, so `/var/home2` is not under
169///  `/var/home`.
170///
171/// In TS you'd write (pseudocode):
172/// ```ts
173/// const mountOf = (table: MountFacts[], path: string) => walkFromRoot(table, path) ?? longestMount(table, path);
174/// ```
175pub fn mount_holding<'table>(table: &'table [MountFacts], path: &Path) -> Option<&'table MountFacts> {
176    let known: std::collections::HashSet<u64> = table.iter().map(|mount: &MountFacts| { return mount.mount_id; }).collect();
177    let mut prefixes: Vec<&Path> = path.ancestors().collect();
178    prefixes.reverse();
179    let mut current: Option<&MountFacts> = None;
180    for prefix in prefixes {
181        loop {
182            let here = |mount: &&MountFacts| -> bool { return mount.mount_point == prefix; };
183            let on_top = |mount: &&MountFacts| -> bool {
184                return match current {
185                    Some(held) => mount.parent_id == held.mount_id,
186                    None => !known.contains(&mount.parent_id),
187                };
188            };
189            let linked: Option<&MountFacts> = table.iter().rev().filter(here).find(on_top);
190            // A mount whose parent is not in the table cannot be placed by ids; it is taken only when nothing placed has landed here.
191            let unlinked: Option<&MountFacts> = if linked.is_none() && current.is_some_and(|held: &MountFacts| { return held.mount_point != prefix; }) {
192                table.iter().rev().filter(here).find(|mount: &&MountFacts| { return !known.contains(&mount.parent_id); })
193            } else {
194                None
195            };
196            match linked.or(unlinked) {
197                Some(found) if current.is_none_or(|held: &MountFacts| { return !std::ptr::eq(held, found); }) => current = Some(found),
198                _ => break,
199            }
200        }
201    }
202    return current.or_else(|| { return longest_mount_holding(table, path); });
203}
204
205/// What:
206///  The mount that holds `path` according to the kernel's table now.
207///  The path is made absolute and symlink-free first, because the table lists real paths.
208/// Why:
209///   The failure is a text a person can read: the table could not be read, or the path does not exist.
210///
211/// In TS you'd write (pseudocode):
212/// ```ts
213/// const facts = mountOf(parseMountTable(await readFile(MOUNTINFO_PATH)), await realpath(path));
214/// ```
215pub fn read_mount_facts(path: &Path) -> Result<Option<MountFacts>, String> {
216    let real: PathBuf = std::fs::canonicalize(path)
217        .map_err(|error| { return format!("cannot resolve {}: {error}", path.display()); })?;
218    let text: Vec<u8> = std::fs::read(MOUNTINFO_PATH)
219        .map_err(|error| { return format!("cannot read {MOUNTINFO_PATH}: {error}"); })?;
220    let table: Vec<MountFacts> = parse_mount_table(text.as_slice());
221    return Ok(mount_holding(table.as_slice(), real.as_path()).cloned());
222}
223
224/// What:
225///  Decode the `\ooo` octal escapes of a mountinfo field exactly as libmount's `unmangle_to_buffer` does:
226///  a backslash followed by three octal digits becomes one byte,
227///  anything else stays.
228/// Why:
229///   The kernel writes space,
230///  tab,
231///  newline and backslash in mount points as `\040`,
232///  `\011`,
233///  `\012` and `\134`.
234///
235/// In TS you'd write (pseudocode):
236/// ```ts
237/// field.replace(/\\([0-7]{3})/g, (_, octal) => String.fromCharCode(parseInt(octal, 8) & 0xff));
238/// ```
239pub fn unmangle_octal(field: &[u8]) -> Vec<u8> {
240    let mut decoded: Vec<u8> = Vec::with_capacity(field.len());
241    let mut index: usize = 0;
242    while index < field.len() {
243        let is_escape: bool = field[index] == b'\\'
244            && index + 3 < field.len()
245            && field[index + 1..index + 4]
246                .iter()
247                .all(|digit: &u8| { return (b'0'..=b'7').contains(digit); });
248        if is_escape {
249            let value: u32 = 64 * u32::from(field[index + 1] & 7)
250                + 8 * u32::from(field[index + 2] & 7)
251                + u32::from(field[index + 3] & 7);
252            // The C code stores the value in a `char`, so `\777` wraps to 0xff.
253            decoded.push((value & 0xff) as u8);
254            index += 4;
255        } else {
256            decoded.push(field[index]);
257            index += 1;
258        }
259    }
260    return decoded;
261}
262
263/// Mount option controls stay out of the release executable.
264#[cfg(test)]
265#[path = "fs_mount_options_tests.rs"]
266mod tests;