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