Skip to main content

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;