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