git_policy_cli/filesystem_identity_mount.rs
1//! What:
2//! The Linux filesystem UUID of the filesystem holding a path,
3//! read in-process from the kernel's mount table and the udev database instead of by starting `findmnt`.
4//! Why:
5//! `findmnt --target <path> --output=UUID --noheadings` is the most expensive process of a wrapped commit,
6//! and the recorded identity must stay byte-identical to what it prints;
7//! every shape this module does not reproduce exactly is reported as unknown so the caller spawns `findmnt` as before.
8//! `doc/planning/cli-git-spawn-and-sync-reduction.md` (section "Filesystem identity without `findmnt`")
9//! holds the algorithm read from util-linux and the measurements.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! const { stdout } = await nanoSpawn('findmnt', ['--target', path, '--output=UUID', '--noheadings']);
14//! ```
15
16/// `Path` is a borrowed filesystem path.
17use std::path::Path;
18
19/// Where the kernel lists the mounts of this process's mount namespace,
20/// the file `findmnt` reads first.
21const MOUNTINFO_PATH: &str = "/proc/self/mountinfo";
22
23/// Where udev keeps one record per block device, named `b<major>:<minor>`.
24const UDEV_DATA_DIRECTORY: &str = "/run/udev/data";
25
26/// The sysfs directories libudev looks a block device up in by name, in its order.
27const SYSFS_BLOCK_DIRECTORIES: [&str; 2] = ["/sys/class/block", "/sys/bus/block/devices"];
28
29/// The udev property `findmnt` reads for the UUID column.
30const UDEV_UUID_PROPERTY_LINE: &str = "E:ID_FS_UUID_ENC=";
31
32/// Longest UUID text accepted,
33/// the same bound as the identity payload normalizer.
34const MAX_UUID_LENGTH: usize = 512;
35
36/// What:
37/// What `findmnt` would print for the UUID column of a path,
38/// as far as this module can prove it without starting `findmnt`.
39/// Why:
40/// The caller records an identity from `Text`,
41/// skips `findmnt` for `Absent`,
42/// and spawns `findmnt` for `Unknown`.
43///
44/// In TS you'd write (pseudocode):
45/// ```ts
46/// type MountUuid = { kind: 'text'; text: string } | { kind: 'absent' } | { kind: 'unknown' };
47/// ```
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub enum MountUuid {
50 /// `findmnt` prints exactly this text and a newline.
51 Text(String),
52 /// `findmnt` prints an empty line:
53 /// the mounted source is a pseudo filesystem with no UUID.
54 Absent,
55 /// This module cannot prove what `findmnt` prints.
56 Unknown,
57}
58
59/// What:
60/// One line of `/proc/self/mountinfo`,
61/// with the escapes of the mount point and the source undone.
62/// Why:
63/// Only the mount ID,
64/// the mount point and the source take part in finding the mount of a path and its device.
65///
66/// In TS you'd write (pseudocode):
67/// ```ts
68/// type MountEntry = { id: number; mountPoint: Uint8Array; source: Uint8Array };
69/// ```
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct MountEntry {
72 /// The kernel's mount ID,
73 /// the first field,
74 /// which `statx` reports as `stx_mnt_id`.
75 pub id: u64,
76 /// The mount point as seen from this process's root,
77 /// octal escapes decoded.
78 pub mount_point: Vec<u8>,
79 /// The mount source after the dash separator,
80 /// octal escapes decoded.
81 pub source: Vec<u8>,
82}
83
84/// What:
85/// Decode the `\ooo` octal escapes of a mountinfo field exactly as libmount's `unmangle_to_buffer` does:
86/// a backslash followed by three octal digits becomes one byte,
87/// anything else stays.
88/// Why:
89/// The kernel writes space,
90/// tab,
91/// newline and backslash in mount points as `\040`,
92/// `\011`,
93/// `\012` and `\134`.
94///
95/// In TS you'd write (pseudocode):
96/// ```ts
97/// field.replace(/\\([0-7]{3})/g, (_, octal) => String.fromCharCode(parseInt(octal, 8) & 0xff));
98/// ```
99pub fn unmangle_octal(field: &[u8]) -> Vec<u8> {
100 let mut decoded: Vec<u8> = Vec::with_capacity(field.len());
101 let mut index: usize = 0;
102 while index < field.len() {
103 let is_escape: bool = field[index] == b'\\'
104 && index + 3 < field.len()
105 && field[index + 1..index + 4]
106 .iter()
107 .all(|digit: &u8| { return (b'0'..=b'7').contains(digit); });
108 if is_escape {
109 let value: u32 = 64 * u32::from(field[index + 1] & 7)
110 + 8 * u32::from(field[index + 2] & 7)
111 + u32::from(field[index + 3] & 7);
112 // The C code stores the value in a `char`, so `\777` wraps to 0xff.
113 decoded.push((value & 0xff) as u8);
114 index += 4;
115 } else {
116 decoded.push(field[index]);
117 index += 1;
118 }
119 }
120 return decoded;
121}
122
123/// What:
124/// Decode the `\xHH` hexadecimal escapes of a udev property exactly as libmount's `unhexmangle_to_buffer` does:
125/// a backslash,
126/// `x` and two hexadecimal digits become one byte,
127/// anything else stays.
128/// Why:
129/// `ID_FS_UUID_ENC` is udev's escaped spelling of the UUID,
130/// which `findmnt` decodes before printing.
131///
132/// In TS you'd write (pseudocode):
133/// ```ts
134/// value.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
135/// ```
136pub fn unmangle_hex(value: &[u8]) -> Vec<u8> {
137 let mut decoded: Vec<u8> = Vec::with_capacity(value.len());
138 let mut index: usize = 0;
139 while index < value.len() {
140 let is_escape: bool = value[index] == b'\\'
141 && index + 3 < value.len()
142 && value[index + 1] == b'x'
143 && value[index + 2].is_ascii_hexdigit()
144 && value[index + 3].is_ascii_hexdigit();
145 if is_escape {
146 let high: u8 = hex_digit_value(value[index + 2]);
147 let low: u8 = hex_digit_value(value[index + 3]);
148 decoded.push((high << 4) | low);
149 index += 4;
150 } else {
151 decoded.push(value[index]);
152 index += 1;
153 }
154 }
155 return decoded;
156}
157
158/// What:
159/// The value of one ASCII hexadecimal digit.
160/// Why:
161/// `unmangle_hex` has checked the digit already.
162///
163/// In TS you'd write (pseudocode):
164/// ```ts
165/// parseInt(String.fromCharCode(digit), 16)
166/// ```
167fn hex_digit_value(digit: u8) -> u8 {
168 match digit {
169 b'0'..=b'9' => return digit - b'0',
170 b'a'..=b'f' => return digit - b'a' + 10,
171 _ => return digit - b'A' + 10,
172 }
173}
174
175/// What:
176/// Parse the text of `/proc/self/mountinfo`,
177/// or nothing when any line does not have the documented shape.
178/// Why:
179/// A line this parser cannot read might be the one `findmnt` would choose,
180/// so one unreadable line makes the whole answer unknown.
181///
182/// In TS you'd write (pseudocode):
183/// ```ts
184/// function parseMountinfo(text: Uint8Array): MountEntry[] | undefined;
185/// ```
186pub fn parse_mountinfo(text: &[u8]) -> Option<Vec<MountEntry>> {
187 let mut entries: Vec<MountEntry> = Vec::new();
188 for line in text.split(|byte: &u8| { return *byte == b'\n'; }) {
189 if line.is_empty() {
190 continue;
191 }
192 entries.push(parse_mountinfo_line(line)?);
193 }
194 return Some(entries);
195}
196
197/// What:
198/// Parse one mountinfo line:
199/// `id parent major:minor root mount-point options [optional...] - fstype source super-options`.
200/// Why:
201/// The fields are separated by single spaces because the kernel escapes spaces inside a field.
202///
203/// In TS you'd write (pseudocode):
204/// ```ts
205/// function parseMountinfoLine(line: Uint8Array): MountEntry | undefined;
206/// ```
207fn parse_mountinfo_line(line: &[u8]) -> Option<MountEntry> {
208 let fields: Vec<&[u8]> = line.split(|byte: &u8| { return *byte == b' '; }).collect();
209 let separator: usize = fields.iter().position(|field: &&[u8]| { return *field == b"-"; })?;
210 // Six fixed fields precede the optional ones,
211 // and the separator is followed by the file system type,
212 // the source and the super options.
213 if separator < 6 || fields.len() < separator + 4 {
214 return None;
215 }
216 let id: u64 = std::str::from_utf8(fields[0]).ok()?.parse::<u64>().ok()?;
217 return Some(MountEntry {
218 id,
219 mount_point: unmangle_octal(fields[4]),
220 source: unmangle_octal(fields[separator + 2]),
221 });
222}
223
224/// What:
225/// Every entry whose mount point is the longest ancestor-or-self of `path` that any entry mounts on,
226/// in file order.
227/// Why:
228/// `findmnt --target` walks the path up one component at a time until some entry's mount point equals it,
229/// then prints every entry with that mount point
230/// (`mnt_table_find_mountpoint` followed by `add_matching_lines`).
231///
232/// In TS you'd write (pseudocode):
233/// ```ts
234/// function entriesAtLongestMountPoint(entries: MountEntry[], path: Uint8Array): MountEntry[];
235/// ```
236pub fn entries_at_longest_mount_point<'entries>(
237 entries: &'entries [MountEntry],
238 path: &[u8],
239) -> Vec<&'entries MountEntry> {
240 let mut candidate: &[u8] = path;
241 loop {
242 let found: Vec<&MountEntry> = entries
243 .iter()
244 .filter(|entry: &&MountEntry| { return entry.mount_point.as_slice() == candidate; })
245 .collect();
246 if !found.is_empty() {
247 return found;
248 }
249 match candidate.iter().rposition(|byte: &u8| { return *byte == b'/'; }) {
250 Some(0) if candidate.len() > 1 => candidate = &candidate[..1],
251 Some(position) if position > 0 => candidate = &candidate[..position],
252 _ => return Vec::new(),
253 }
254 }
255}
256
257/// What:
258/// The UUID `findmnt` prints for a source that is not a device path,
259/// or nothing when the source needs a closer look.
260/// Why:
261/// `findmnt` looks such a name up as a sysfs block device in both libudev directories
262/// and lets `libblkid` open it as a relative path;
263/// only a name that exists in none of them provably has no UUID.
264///
265/// In TS you'd write (pseudocode):
266/// ```ts
267/// function pseudoSourceHasNoUuid(source: string): boolean;
268/// ```
269pub fn pseudo_source_has_no_uuid(source: &[u8]) -> bool {
270 use std::os::unix::ffi::OsStrExt;
271 if source.is_empty()
272 || source.contains(&b'/')
273 || source.contains(&b'=')
274 || source.contains(&0)
275 || source == b"."
276 || source == b".."
277 {
278 return false;
279 }
280 let name: &std::ffi::OsStr = std::ffi::OsStr::from_bytes(source);
281 for directory in SYSFS_BLOCK_DIRECTORIES {
282 if !is_surely_missing(Path::new(directory).join(name).as_path()) {
283 return false;
284 }
285 }
286 return is_surely_missing(Path::new(name));
287}
288
289/// What:
290/// Whether a path certainly does not exist:
291/// the lookup reported "not found" and nothing else.
292/// Why:
293/// A permission error or any other failure leaves the question open.
294///
295/// In TS you'd write (pseudocode):
296/// ```ts
297/// try { await lstat(path); return false; } catch (e) { return e.code === 'ENOENT'; }
298/// ```
299fn is_surely_missing(path: &Path) -> bool {
300 match std::fs::symlink_metadata(path) {
301 Ok(_) => return false,
302 Err(error) => return error.kind() == std::io::ErrorKind::NotFound,
303 }
304}
305
306/// What:
307/// The `ID_FS_UUID_ENC` value of a udev database record,
308/// decoded,
309/// or nothing unless the record holds exactly one such line whose value `findmnt` prints unchanged.
310/// Why:
311/// The accepted text is limited to ASCII letters,
312/// digits,
313/// dots and dashes,
314/// which libsmartcols writes verbatim;
315/// anything else may be re-encoded when printed.
316///
317/// In TS you'd write (pseudocode):
318/// ```ts
319/// function udevUuid(record: string): string | undefined;
320/// ```
321pub fn udev_uuid_of_record(record: &[u8]) -> Option<String> {
322 let prefix: &[u8] = UDEV_UUID_PROPERTY_LINE.as_bytes();
323 let mut values = record
324 .split(|byte: &u8| { return *byte == b'\n'; })
325 .filter(|line: &&[u8]| { return line.starts_with(prefix); });
326 let value: &[u8] = &values.next()?[prefix.len()..];
327 if values.next().is_some() {
328 return None;
329 }
330 let decoded: Vec<u8> = unmangle_hex(value);
331 let printable: bool = decoded
332 .iter()
333 .all(|byte: &u8| { return byte.is_ascii_alphanumeric() || *byte == b'-' || *byte == b'.'; });
334 if decoded.is_empty() || decoded.len() > MAX_UUID_LENGTH || !printable {
335 return None;
336 }
337 return String::from_utf8(decoded).ok();
338}
339
340/// What:
341/// The mount ID the kernel reports for a path.
342/// Why:
343/// It names the mount that really holds the path,
344/// including when a later mount covers an ancestor's mount point,
345/// which a walk over mount point names cannot see.
346///
347/// In TS you'd write (pseudocode):
348/// ```ts
349/// function mountIdOf(path: string): bigint | undefined; // statx(2) stx_mnt_id
350/// ```
351fn mount_id_of(path: &Path) -> Option<u64> {
352 use std::os::unix::ffi::OsStrExt;
353 let c_path: std::ffi::CString = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
354 // SAFETY: `statx` is plain data that the call fills in, so all-zero bytes are a valid value.
355 let mut buffer: libc::statx = unsafe { std::mem::zeroed() };
356 // SAFETY: `c_path` is a NUL-terminated string that outlives the call, and `buffer` is a valid
357 // `statx` the kernel writes at most once.
358 let status: libc::c_int = unsafe {
359 libc::statx(
360 libc::AT_FDCWD,
361 c_path.as_ptr(),
362 libc::AT_NO_AUTOMOUNT | libc::AT_SYMLINK_NOFOLLOW,
363 libc::STATX_MNT_ID,
364 &mut buffer,
365 )
366 };
367 if status != 0 || buffer.stx_mask & libc::STATX_MNT_ID == 0 {
368 return None;
369 }
370 return Some(buffer.stx_mnt_id);
371}
372
373/// What:
374/// The UUID text of the block device a mount source names,
375/// read from the udev record libudev would read.
376/// Why:
377/// `findmnt` resolves the source to its canonical device node,
378/// finds the sysfs block device of that name,
379/// and reads `ID_FS_UUID_ENC` from `/run/udev/data/b<major>:<minor>`;
380/// every step that cannot be reproduced exactly gives up.
381///
382/// In TS you'd write (pseudocode):
383/// ```ts
384/// function udevUuidOfSource(source: Uint8Array): string | undefined;
385/// ```
386fn udev_uuid_of_device_source(source: &[u8]) -> Option<String> {
387 use std::os::unix::ffi::OsStrExt;
388 use std::os::unix::fs::{FileTypeExt, MetadataExt};
389 let node: std::path::PathBuf = std::fs::canonicalize(Path::new(std::ffi::OsStr::from_bytes(source))).ok()?;
390 let name: &std::ffi::OsStr = node.strip_prefix("/dev").ok()?.as_os_str();
391 if name.is_empty() || name.as_bytes().contains(&b'/') {
392 return None;
393 }
394 let metadata: std::fs::Metadata = std::fs::metadata(node.as_path()).ok()?;
395 if !metadata.file_type().is_block_device() {
396 return None;
397 }
398 let device: String = format!("{}:{}", libc::major(metadata.rdev()), libc::minor(metadata.rdev()));
399 // libudev reads the device number from the sysfs entry of that name; both must agree.
400 let sysfs_device: String =
401 std::fs::read_to_string(Path::new(SYSFS_BLOCK_DIRECTORIES[0]).join(name).join("dev")).ok()?;
402 if sysfs_device.trim_end() != device {
403 return None;
404 }
405 let record: Vec<u8> = std::fs::read(Path::new(UDEV_DATA_DIRECTORY).join(format!("b{device}"))).ok()?;
406 return udev_uuid_of_record(record.as_slice());
407}
408
409/// What:
410/// The verdict for one canonical path,
411/// given the text of the mount table and the mount ID the kernel reports for the path.
412/// Why:
413/// This is the pure decision:
414/// one entry at the winning mount point,
415/// the same mount the kernel reports,
416/// then the source decides between a device lookup,
417/// a pseudo source and unknown.
418///
419/// In TS you'd write (pseudocode):
420/// ```ts
421/// function mountUuidFromTable({ mountinfo, path, mountId }): MountUuid;
422/// ```
423pub fn mount_uuid_from_table(mountinfo: &[u8], path: &[u8], mount_id: u64) -> MountUuid {
424 let well_formed: bool = path.first() == Some(&b'/')
425 && !path.windows(2).any(|pair: &[u8]| { return pair == b"//"; })
426 && (path.len() == 1 || !path.ends_with(b"/"));
427 if !well_formed {
428 return MountUuid::Unknown;
429 }
430 let Some(entries) = parse_mountinfo(mountinfo) else {
431 return MountUuid::Unknown;
432 };
433 let winners: Vec<&MountEntry> = entries_at_longest_mount_point(entries.as_slice(), path);
434 // Several entries on one mount point make `findmnt` print several lines.
435 let [winner] = winners.as_slice() else {
436 return MountUuid::Unknown;
437 };
438 if winner.id != mount_id {
439 return MountUuid::Unknown;
440 }
441 if winner.source.starts_with(b"/dev/") {
442 return match udev_uuid_of_device_source(winner.source.as_slice()) {
443 Some(text) => MountUuid::Text(text),
444 None => MountUuid::Unknown,
445 };
446 }
447 if pseudo_source_has_no_uuid(winner.source.as_slice()) {
448 return MountUuid::Absent;
449 }
450 return MountUuid::Unknown;
451}
452
453/// What:
454/// The verdict for a canonical path on the running system.
455/// Why:
456/// The wrapper asks this before it starts `findmnt`.
457///
458/// In TS you'd write (pseudocode):
459/// ```ts
460/// function mountUuid(path: string): MountUuid;
461/// ```
462pub fn mount_uuid(path: &Path) -> MountUuid {
463 use std::os::unix::ffi::OsStrExt;
464 let Some(mount_id) = mount_id_of(path) else {
465 return MountUuid::Unknown;
466 };
467 let Ok(mountinfo) = std::fs::read(MOUNTINFO_PATH) else {
468 return MountUuid::Unknown;
469 };
470 return mount_uuid_from_table(mountinfo.as_slice(), path.as_os_str().as_bytes(), mount_id);
471}
472
473/// Controls for the pure decisions and the differential test against `findmnt`.
474#[cfg(test)]
475#[path = "filesystem_identity_mount_tests.rs"]
476mod tests;