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