Skip to main content

git_policy_cli/
filesystem_identity.rs

1//! What:
2//!  The filesystem identity recorded with every real `index.lock` a transaction creates,
3//!       spelled exactly as the incumbent's `@monochromatic-dev/fs-id-core` spells it.
4//! Why:
5//!  Recovery removes a real `index.lock` only when its device,
6//!  inode and filesystem
7//!      identity all match the journal;
8//!  the incumbent compares the recorded `fsId` with the one
9//!      it resolves now,
10//!  so both wrappers must resolve the same text for the same filesystem
11//!      (`package/fs-id/core/src/platform-resolvers.ts`,
12//!  `parsers.ts`).
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! const { value } = await resolveFsId({ path: lockPath, emitDiagnostics: false });
17//! ```
18
19/// Trimming with the incumbent's `.trim()` semantics.
20use super::js_text::trim_javascript;
21/// Starts that ride out a short shortage of processes.
22use super::start_retry::RetryStart;
23/// The in-process answer for the `f_fsid` GNU `stat` would print.
24#[cfg(target_os = "linux")]
25use super::filesystem_identity_fsid::f_fsid_text;
26/// The in-process answer for the UUID `findmnt` would print.
27#[cfg(target_os = "linux")]
28use super::filesystem_identity_mount::{MountUuid, mount_uuid};
29/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
30use std::path::{Path, PathBuf};
31
32/// Longest accepted platform payload.
33const MAX_PAYLOAD_LENGTH: usize = 512;
34
35/// Characters a normalized payload may hold.
36const SAFE_PAYLOAD_CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyz0123456789-.";
37
38/// Characters a macOS device-node path may hold.
39const SAFE_DEVICE_CHARACTERS: &str =
40    "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/_-.";
41
42/// What:
43///  The mechanism that supplied an identity,
44///  which names its prefix.
45/// Why:
46///   `SOURCE_PREFIXES` in the incumbent:
47///  stable sources and degraded fallbacks are told
48///       apart in the text itself.
49///
50/// In TS you'd write (pseudocode):
51/// ```ts
52/// type FsIdSource = 'fs-uuid' | 'volume-uuid' | 'volume-serial' | 'f-fsid' | 'device-number';
53/// ```
54#[derive(Clone, Copy, Debug, Eq, PartialEq)]
55pub enum FsIdSource {
56    /// A Linux filesystem UUID from `findmnt`.
57    FsUuid,
58    /// A macOS Volume UUID from `diskutil`.
59    VolumeUuid,
60    /// A Windows volume serial number.
61    VolumeSerial,
62    /// A Linux `f_fsid`, spelled as `stat --file-system --format=%i` spells it and read in-process.
63    FFsid,
64    /// A device number.
65    DeviceNumber,
66}
67
68/// What:
69///  The prefix of one source.
70/// Why:
71///   The identity text is `<prefix><payload>`.
72///
73/// In TS you'd write (pseudocode):
74/// ```ts
75/// SOURCE_PREFIXES[source]
76/// ```
77pub fn source_prefix(source: FsIdSource) -> &'static str {
78    match source {
79        FsIdSource::FsUuid => return "fs-uuid_",
80        FsIdSource::VolumeUuid => return "volume-uuid_",
81        FsIdSource::VolumeSerial => return "volume-serial_",
82        FsIdSource::FFsid => return "f-fsid_",
83        FsIdSource::DeviceNumber => return "device-number_",
84    }
85}
86
87/// What:
88///  Normalize one command-supplied payload:
89///  trim,
90///  lowercase,
91///  then accept only non-empty
92///       safe ASCII up to 512 characters that is not a lone `-`.
93/// Why:
94///   `normalizeIdentityPayload`;
95///  anything else is no identity.
96///
97/// In TS you'd write (pseudocode):
98/// ```ts
99/// function normalizeIdentityPayload(value: string): string; // throws when unsafe
100/// ```
101pub fn normalize_identity_payload(value: &str) -> Option<String> {
102    let normalized: String = trim_javascript(value).to_lowercase();
103    // `chars().count()` counts characters, as JavaScript's `length` counts code units; both
104    // are equal for the ASCII text that alone can pass.
105    let length: usize = normalized.chars().count();
106    if length == 0
107        || length > MAX_PAYLOAD_LENGTH
108        || normalized == "-"
109        || trim_javascript(normalized.as_str()) != normalized
110    {
111        return None;
112    }
113    for character in normalized.chars() {
114        if !SAFE_PAYLOAD_CHARACTERS.contains(character) {
115            return None;
116        }
117    }
118    return Some(normalized);
119}
120
121/// What:
122///  The identity text `<prefix><payload>` for a normalized payload.
123/// Why:
124///   `createFsId`.
125///
126/// In TS you'd write (pseudocode):
127/// ```ts
128/// createFsId({ source, payload })
129/// ```
130pub fn create_fs_id(source: FsIdSource, payload: &str) -> Option<String> {
131    return Some(format!(
132        "{}{}",
133        source_prefix(source),
134        normalize_identity_payload(payload)?
135    ));
136}
137
138/// What:
139///  The mounted device node in POSIX `df -P` output:
140///  the first field of the last
141///       non-empty row that starts with `/dev/` and holds only safe characters.
142/// Why:
143///   `parseDfDevice` on macOS.
144///
145/// In TS you'd write (pseudocode):
146/// ```ts
147/// function parseDfDevice(output: string): string; // throws when none
148/// ```
149pub fn parse_df_device(output: &str) -> Option<String> {
150    let mut found: Option<String> = None;
151    for line in output.split('\n') {
152        let trimmed: &str = trim_javascript(line);
153        if trimmed.is_empty() {
154            continue;
155        }
156        let end: usize = trimmed.find([' ', '\t']).unwrap_or(trimmed.len());
157        let device: &str = &trimmed[..end];
158        if device.starts_with("/dev/") && device.chars().all(is_safe_device_character) {
159            // A later row wins: the incumbent scans the rows in reverse and takes the first.
160            found = Some(String::from(device));
161        }
162    }
163    return found;
164}
165
166/// What:
167///  Whether a character may appear in a device-node path.
168/// Why:
169///   A named predicate keeps the scan free of closures.
170///
171/// In TS you'd write (pseudocode):
172/// ```ts
173/// SAFE_DEVICE_CHARACTERS.includes(character)
174/// ```
175fn is_safe_device_character(character: char) -> bool {
176    return SAFE_DEVICE_CHARACTERS.contains(character);
177}
178
179/// What:
180///  The `VolumeUUID` string of a `diskutil info -plist` property list,
181///  normalized.
182/// Why:
183///   `parseDiskutilVolumeUuid` on macOS.
184///
185/// In TS you'd write (pseudocode):
186/// ```ts
187/// function parseDiskutilVolumeUuid(output: string): string; // throws when absent
188/// ```
189pub fn parse_diskutil_volume_uuid(output: &str) -> Option<String> {
190    let key: &str = "<key>VolumeUUID</key>";
191    let open: &str = "<string>";
192    let close: &str = "</string>";
193    let key_index: usize = output.find(key)?;
194    let open_index: usize = key_index + key.len() + output[key_index + key.len()..].find(open)?;
195    let value_start: usize = open_index + open.len();
196    let value_end: usize = value_start + output[value_start..].find(close)?;
197    return normalize_identity_payload(&output[value_start..value_end]);
198}
199
200/// What:
201///  The uppercase drive root `X:\` of a Windows path,
202///  or nothing for a path without one.
203/// Why:
204///   `windowsDriveRoot`:
205///  the drive letter feeds a fixed PowerShell query.
206///
207/// In TS you'd write (pseudocode):
208/// ```ts
209/// function windowsDriveRoot(path: string): string; // throws without a drive root
210/// ```
211pub fn windows_drive_root(path: &str) -> Option<String> {
212    let mut characters: std::str::Chars<'_> = path.chars();
213    let letter: char = characters.next()?.to_ascii_lowercase();
214    let colon: char = characters.next()?;
215    let separator: char = characters.next()?;
216    if !letter.is_ascii_lowercase() || colon != ':' || (separator != '\\' && separator != '/') {
217        return None;
218    }
219    return Some(format!("{}:\\", letter.to_ascii_uppercase()));
220}
221
222/// What:
223///  Run one platform command and return its standard output as text,
224///  or nothing when it
225///       cannot start or exits unsuccessfully.
226/// Why:
227///   The incumbent's `nano-spawn` call throws on either;
228///  both send it to the fallback.
229///
230/// In TS you'd write (pseudocode):
231/// ```ts
232/// const { stdout } = await nanoSpawn(command, args);
233/// ```
234fn command_output(command: &str, arguments: &[&std::ffi::OsStr]) -> Option<String> {
235    let output: std::process::Output = std::process::Command::new(command)
236        .args(arguments)
237        .stdin(std::process::Stdio::null())
238        .stderr(std::process::Stdio::null())
239        .output_retrying()
240        .ok()?;
241    if !output.status.success() {
242        return None;
243    }
244    return Some(String::from_utf8_lossy(output.stdout.as_slice()).into_owned());
245}
246
247/// What:
248///  The Linux identity:
249///  the filesystem UUID from `findmnt`,
250///  or the degraded `f_fsid`
251///       the kernel reports, spelled as GNU `stat` spells it.
252/// Why:
253///   `resolveLinuxFsId`,
254///  step for step.
255///
256/// In TS you'd write (pseudocode):
257/// ```ts
258/// async function resolveLinuxFsId({ path }): Promise<FsIdResolution>;
259/// ```
260#[cfg(target_os = "linux")]
261fn linux_fs_id(path: &Path) -> Option<String> {
262    return linux_fs_id_with(path, command_output);
263}
264
265/// What:
266///  The Linux identity of `linux_fs_id` with the mechanism that starts a platform command passed in.
267/// Why:
268///   A control that counts which commands start can only do so by replacing the mechanism.
269///
270/// In TS you'd write (pseudocode):
271/// ```ts
272/// async function resolveLinuxFsId({ path, run }): Promise<FsIdResolution>;
273/// ```
274#[cfg(target_os = "linux")]
275fn linux_fs_id_with(
276    path: &Path,
277    run_command: fn(&str, &[&std::ffi::OsStr]) -> Option<String>,
278) -> Option<String> {
279    let target: &std::ffi::OsStr = path.as_os_str();
280    // The mount table and the udev record answer most paths without starting `findmnt`;
281    // a shape they cannot answer exactly is `Unknown` and still spawns it.
282    let in_process: MountUuid = mount_uuid(path);
283    match in_process {
284        MountUuid::Text(text) => {
285            if let Some(identity) = create_fs_id(FsIdSource::FsUuid, text.as_str()) {
286                return Some(identity);
287            }
288        }
289        MountUuid::Absent => {}
290        MountUuid::Unknown => {
291            if let Some(output) = run_command(
292                "findmnt",
293                &[
294                    std::ffi::OsStr::new("--target"),
295                    target,
296                    std::ffi::OsStr::new("--output=UUID"),
297                    std::ffi::OsStr::new("--noheadings"),
298                ],
299            ) && let Some(identity) = create_fs_id(FsIdSource::FsUuid, output.as_str())
300            {
301                return Some(identity);
302            }
303        }
304    }
305    // The degraded identity needs no process: the kernel answers `statfs` in-process, so releasing a lock this
306    // process holds still works when the host refuses every start.
307    let fallback: String = f_fsid_text(path)?;
308    return create_fs_id(FsIdSource::FFsid, fallback.as_str());
309}
310
311/// What:
312///  The filesystem identity of the filesystem holding `path`,
313///  or nothing when no
314///       mechanism answers.
315/// Why:
316///   The incumbent canonicalizes the path first and then asks the platform's mechanism;
317///       on macOS and Windows the commands are the incumbent's own.
318///
319/// In TS you'd write (pseudocode):
320/// ```ts
321/// (await resolveFsId({ path })).value
322/// ```
323pub fn filesystem_identity(path: &Path) -> Option<String> {
324    let canonical: PathBuf = std::fs::canonicalize(path).ok()?;
325    #[cfg(target_os = "linux")]
326    {
327        return linux_fs_id(canonical.as_path());
328    }
329    #[cfg(target_os = "macos")]
330    {
331        return darwin_fs_id(canonical.as_path());
332    }
333    // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
334    #[cfg(windows)]
335    {
336        return windows_fs_id(canonical.as_path());
337    }
338    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
339    {
340        let _ = canonical;
341        return None;
342    }
343}
344
345/// What:
346///  The macOS identity:
347///  the Volume UUID through `df -P` and `diskutil info -plist`,
348///  or the
349///       degraded device number from BSD `stat -f %d`.
350/// Why:
351///   `resolveDarwinFsId`,
352///  step for step.
353///
354/// In TS you'd write (pseudocode):
355/// ```ts
356/// async function resolveDarwinFsId({ path }): Promise<FsIdResolution>;
357/// ```
358#[cfg(target_os = "macos")]
359fn darwin_fs_id(path: &Path) -> Option<String> {
360    let target: &std::ffi::OsStr = path.as_os_str();
361    if let Some(mounts) = command_output("df", &[std::ffi::OsStr::new("-P"), target])
362        && let Some(device) = parse_df_device(mounts.as_str())
363        && let Some(plist) = command_output(
364            "diskutil",
365            &[
366                std::ffi::OsStr::new("info"),
367                std::ffi::OsStr::new("-plist"),
368                std::ffi::OsStr::new(device.as_str()),
369            ],
370        )
371        && let Some(uuid) = parse_diskutil_volume_uuid(plist.as_str())
372    {
373        return Some(format!("{}{uuid}", source_prefix(FsIdSource::VolumeUuid)));
374    }
375    let number: String = command_output(
376        "stat",
377        &[
378            std::ffi::OsStr::new("-f"),
379            std::ffi::OsStr::new("%d"),
380            target,
381        ],
382    )?;
383    return create_fs_id(FsIdSource::DeviceNumber, number.as_str());
384}
385
386/// What:
387///  The Windows identity:
388///  the volume serial number through PowerShell.
389/// Why:
390///   `resolveWindowsFsId`'s preferred step;
391///  its device-number fallback is not ported
392///       (recorded in `doc/handover/cli-git-native-transactions.md`).
393///
394/// In TS you'd write (pseudocode):
395/// ```ts
396/// async function resolveWindowsFsId({ path }): Promise<FsIdResolution>;
397/// ```
398// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
399#[cfg(windows)]
400fn windows_fs_id(path: &Path) -> Option<String> {
401    let text: String = path.to_string_lossy().into_owned();
402    if let Some(root) = windows_drive_root(text.as_str()) {
403        let query: String = format!(
404            "(Get-CimInstance Win32_LogicalDisk -Filter \"DeviceID='{}'\").VolumeSerialNumber",
405            &root[..2]
406        );
407        if let Some(output) = command_output(
408            "powershell.exe",
409            &[
410                std::ffi::OsStr::new("-NoLogo"),
411                std::ffi::OsStr::new("-NoProfile"),
412                std::ffi::OsStr::new("-NonInteractive"),
413                std::ffi::OsStr::new("-Command"),
414                std::ffi::OsStr::new(query.as_str()),
415            ],
416        ) && let Some(identity) = create_fs_id(FsIdSource::VolumeSerial, output.as_str())
417        {
418            return Some(identity);
419        }
420    }
421    // The incumbent falls back to Node's device number, the volume serial number that only an
422    // unstable standard-library interface exposes; without it no identity is recorded, and the
423    // landing that needs one stops instead of recording a different spelling.
424    return None;
425}
426
427/// Parser controls and the Linux resolver stay out of the release executable.
428#[cfg(test)]
429#[path = "filesystem_identity_tests.rs"]
430mod tests;