Skip to main content

git_policy_cli/
doctor_filesystem.rs

1//! What:
2//!  The filesystem section of `git cli-git doctor`: what the repository's main checkout sits on (btrfs subvolume,
3//!  btrfs directory, overlayfs, other), whether the mount allows a regular user to delete a subvolume, and the exact
4//!  commands that change what can be changed.
5//! Why:
6//!  Cheap worktrees need a btrfs subvolume, and deleting a worktree that is a subvolume in one step needs the mount
7//!  option `user_subvol_rm_allowed`. Neither is visible to the person until something is slow, so the doctor says what
8//!  holds and prints the command, never runs it. Everything is informational: the section goes to standard error after
9//!  the hook report and never changes the exit status. It reads `statfs`, one inode number, `/proc/self/mountinfo`,
10//!  `/run/ostree-booted` and `/proc/cmdline`, and starts nothing. The facts are gathered apart from the wording
11//!  (`FilesystemFacts` in, text out), so the wording is tested on built facts and the exact-output controls of the
12//!  doctor and the management command stay independent of the host they run on.
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! const section = renderSection(await gatherFacts(mainCheckoutRoot)); // '' when no checkout was found
17//! ```
18
19use super::fs_kind::{FilesystemKind, filesystem_kind, is_subvolume_root};
20use super::fs_mount_options::read_mount_facts;
21use super::shell_quote::shell_quote;
22use std::os::unix::ffi::OsStrExt;
23use std::path::{Path, PathBuf};
24
25/// The first words of the first line of the section, which a caller can cut the section off at.
26pub const SECTION_MARKER: &str = "git cli-git doctor: filesystem:";
27
28/// The mount option that lets a regular user delete a subvolume in one step.
29pub const REMOVAL_OPTION: &str = "user_subvol_rm_allowed";
30
31/// The marker file that systemd creates on an ostree or bootc host.
32const OSTREE_BOOTED_PATH: &str = "/run/ostree-booted";
33
34/// Where an ostree or bootc host mounts the filesystem that the kernel command line's `root=` and `rootflags=` name; `/`
35///  there is a composefs overlay.
36const SYSROOT_PATH: &str = "/sysroot";
37
38/// The kernel command line of the running system.
39const CMDLINE_PATH: &str = "/proc/cmdline";
40
41/// What:
42///  The mount that holds the checkout, reduced to what the section needs.
43/// Why:
44///   The remount command names the mount point, and the option check reads both option lists of the mount.
45///
46/// In TS you'd write (pseudocode):
47/// ```ts
48/// type MountSummary = { mountPoint: string; source: string; hasRemovalOption: boolean };
49/// ```
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct MountSummary {
52    /// Where the filesystem is mounted.
53    pub mount_point: PathBuf,
54    /// The device or source the kernel lists for it.
55    pub source: String,
56    /// Whether `user_subvol_rm_allowed` is among the mount options or the superblock options.
57    pub has_removal_option: bool,
58}
59
60/// What:
61///  Everything the section is written from.
62///  `Vec<String>` is an owned list of owned text.
63/// Why:
64///   One value holds the host facts, so the wording is a pure function of it.
65///
66/// In TS you'd write (pseudocode):
67/// ```ts
68/// type FilesystemFacts = { root: string; kind: FilesystemKind; rootIsSubvolume: boolean; mount?: MountSummary; rootSource?: string; ostreeBooted: boolean; kernelRootflags: string[] };
69/// ```
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct FilesystemFacts {
72    /// The repository's main checkout.
73    pub root: PathBuf,
74    /// The filesystem kind holding it.
75    pub kind: FilesystemKind,
76    /// Whether the checkout is a subvolume root (inode 256 on btrfs).
77    pub root_is_subvolume: bool,
78    /// The mount holding it, when the mount table could be read.
79    pub mount: Option<MountSummary>,
80    /// The source of the filesystem the kernel command line's `rootflags=` configures: the mount at `/sysroot` on an
81    /// ostree host, else the mount at `/`.
82    pub root_source: Option<String>,
83    /// Whether the host is an ostree or bootc deployment (`/run/ostree-booted` exists).
84    pub ostree_booted: bool,
85    /// The values of every `rootflags=` word on the running kernel command line, as written there (quotes included).
86    pub kernel_rootflags: Vec<String>,
87}
88
89/// What:
90///  The words of a kernel command line, split as the kernel splits it: at whitespace outside double quotes.
91/// Why:
92///   A double quote toggles quoting wherever it stands in a word, so `rootflags="subvol=@my root"` is one word; the
93///  kernel's `next_arg` (`lib/cmdline.c` and `kernel/params.c`) counts quotes the same way and strips them afterwards.
94///
95/// In TS you'd write (pseudocode):
96/// ```ts
97/// const words = (cmdline: string) => splitOutsideQuotes(cmdline, /\s/);
98/// ```
99fn command_line_words(command_line: &str) -> Vec<&str> {
100    let mut words: Vec<&str> = Vec::new();
101    let mut start: Option<usize> = None;
102    let mut in_quote: bool = false;
103    for (index, character) in command_line.char_indices() {
104        if character.is_whitespace() && !in_quote {
105            if let Some(began) = start.take() {
106                words.push(&command_line[began..index]);
107            }
108            continue;
109        }
110        if start.is_none() {
111            start = Some(index);
112        }
113        if character == '"' {
114            in_quote = !in_quote;
115        }
116    }
117    if let Some(began) = start {
118        words.push(&command_line[began..]);
119    }
120    return words;
121}
122
123/// What:
124///  A kernel argument value without the quote that opens it and the quote that closes it.
125/// Why:
126///   The kernel strips a leading quote from a value and a trailing one when the value had a leading one; quotes inside a
127///  value stay (they only toggled the splitting). The option check reads this form.
128///
129/// In TS you'd write (pseudocode):
130/// ```ts
131/// const kernelValue = (raw: string) => raw.startsWith('"') ? raw.slice(1).replace(/"$/, '') : raw;
132/// ```
133pub fn kernel_value(raw: &str) -> String {
134    let Some(rest) = raw.strip_prefix('"') else { return raw.to_string() };
135    return rest.strip_suffix('"').unwrap_or(rest).to_string();
136}
137
138/// What:
139///  The values of the `rootflags=` words of a kernel command line, as written there (a quoted value keeps its quotes).
140/// Why:
141///   The persistent command on an ostree host replaces that exact value, so it is read from the running system and
142///  never guessed. The value is everything after the first `=`, because the value itself contains `=`. A word quoted as
143///  a whole (`"rootflags=a b"`) is unwrapped first, as the kernel does.
144///
145/// In TS you'd write (pseudocode):
146/// ```ts
147/// const rootflags = (cmdline: string) => words(cmdline).map(unwrap).filter((w) => w.startsWith('rootflags=')).map((w) => w.slice(10));
148/// ```
149pub fn parse_rootflags(command_line: &str) -> Vec<String> {
150    return command_line_words(command_line)
151        .into_iter()
152        .filter_map(|word: &str| {
153            let unwrapped: &str = word.strip_prefix('"').and_then(|inner: &str| { return inner.strip_suffix('"'); }).filter(|inner: &&str| { return !inner.is_empty(); }).unwrap_or(word);
154            return unwrapped.strip_prefix("rootflags=");
155        })
156        .map(str::to_string)
157        .collect();
158}
159
160/// What:
161///  Read the facts for a checkout from the running system.
162/// Why:
163///   A fact that cannot be read becomes the plain answer (`None`, empty list, `false`), and the wording states less.
164///
165/// In TS you'd write (pseudocode):
166/// ```ts
167/// const facts = await gatherFacts(root);
168/// ```
169pub fn gather_facts(root: &Path) -> Option<FilesystemFacts> {
170    let kind: FilesystemKind = filesystem_kind(root).ok()?;
171    let root_is_subvolume: bool = kind == FilesystemKind::Btrfs && is_subvolume_root(root).unwrap_or(false);
172    let mount: Option<MountSummary> = read_mount_facts(root).ok().flatten().map(|facts| {
173        return MountSummary { has_removal_option: facts.has_option(REMOVAL_OPTION), source: facts.source.clone(), mount_point: facts.mount_point };
174    });
175    let ostree_booted: bool = Path::new(OSTREE_BOOTED_PATH).exists();
176    let root_source: Option<String> = read_mount_facts(Path::new(if ostree_booted { SYSROOT_PATH } else { "/" })).ok().flatten().map(|facts| { return facts.source; });
177    let command_line: String = std::fs::read_to_string(CMDLINE_PATH).unwrap_or_default();
178    return Some(FilesystemFacts {
179        root: root.to_path_buf(),
180        kind,
181        root_is_subvolume,
182        mount,
183        root_source,
184        ostree_booted,
185        kernel_rootflags: parse_rootflags(command_line.as_str()),
186    });
187}
188
189/// The wording for a filesystem that is neither btrfs nor overlayfs.
190fn other_lines(facts: &FilesystemFacts) -> Vec<String> {
191    return vec![format!(
192        "{} {} is on a filesystem other than btrfs or overlayfs. `git worktree add` uses Git's own checkout there, and no faster method applies.",
193        SECTION_MARKER,
194        facts.root.display()
195    )];
196}
197
198/// The wording for overlayfs.
199fn overlay_lines(facts: &FilesystemFacts) -> Vec<String> {
200    return vec![
201        format!("{} {} is on overlayfs.", SECTION_MARKER, facts.root.display()),
202        String::from("  Cloning a file's data into another file inside the same overlay works, so the wrapper's copy of ignored files uses clones there; a snapshot does not apply, because snapshots need a btrfs subvolume and the btrfs under an overlay cannot be addressed from inside it."),
203        String::from("  `git worktree add` uses Git's own checkout, which was several times slower on an overlay than on btrfs in one measurement (doc/planning/cli-git-btrfs-worktree.md, section Overlayfs)."),
204    ];
205}
206
207/// The value with the option added: inside the closing quote of a quoted value, at the end of any other.
208fn with_removal_option(raw: &str) -> String {
209    if raw.starts_with('"') && raw.len() > 1 && let Some(inner) = raw.strip_suffix('"') {
210        return format!("{inner},{REMOVAL_OPTION}\"");
211    }
212    return format!("{raw},{REMOVAL_OPTION}");
213}
214
215/// The persistent way to set the option on an ostree host, for the filesystem the kernel command line configures.
216fn kernel_argument_lines(facts: &FilesystemFacts) -> Vec<String> {
217    return match facts.kernel_rootflags.as_slice() {
218        [old] if kernel_value(old).split(',').any(|option: &str| { return option == REMOVAL_OPTION; }) => vec![format!(
219            "  Kept across boots: the running kernel command line already lists `{REMOVAL_OPTION}` in `rootflags=`, so a mount that lacks it was made before that; reboot, and compare with the output of `rpm-ostree kargs`."
220        )],
221        [old] => vec![
222            String::from("  Kept across boots on this ostree host, then reboot (the value is the `rootflags=` of the running kernel command line; compare it with the output of `rpm-ostree kargs` before running):"),
223            format!(
224                "    sudo rpm-ostree kargs {} {}",
225                shell_quote(format!("--delete=rootflags={old}").as_bytes()),
226                shell_quote(format!("--append=rootflags={}", with_removal_option(old)).as_bytes())
227            ),
228            String::from("  Use delete and append, not `--replace`: the value contains `=`, and libostree splits each argument at the first `=`, so `--replace=rootflags=...` reads the old value as only the part before the next `=`."),
229        ],
230        [] => vec![String::from("  Kept across boots: this ostree host's kernel command line has no `rootflags=` word, so the mount options come from somewhere else (for example `/etc/fstab`); add the option there.")],
231        _ => vec![String::from("  Kept across boots: this host's kernel command line has several `rootflags=` words, so no single command is printed; edit them with `rpm-ostree kargs --editor`.")],
232    };
233}
234
235/// The commands that make the removal option active, for the host the facts describe.
236fn removal_option_lines(facts: &FilesystemFacts, mount: &MountSummary) -> Vec<String> {
237    let mount_point: &Path = mount.mount_point.as_path();
238    let mut lines: Vec<String> = vec![
239        format!("  The mount {} lacks `{REMOVAL_OPTION}`. Without it, deleting a worktree that is a subvolume works but unlinks its files one by one; with it, one call deletes the subvolume.", mount_point.display()),
240        format!("  Active until the next boot or unmount: sudo mount --options remount,{REMOVAL_OPTION} {}", shell_quote(mount_point.as_os_str().as_bytes())),
241    ];
242    let on_root_filesystem: bool = facts.root_source.as_deref() == Some(mount.source.as_str());
243    if facts.ostree_booted && on_root_filesystem {
244        lines.extend(kernel_argument_lines(facts));
245    } else if facts.ostree_booted {
246        lines.push(format!("  Kept across boots: {} is not the root filesystem, so the kernel command line's `rootflags=` does not apply to it; add the option to its `/etc/fstab` entry or its systemd mount unit, then reboot.", mount_point.display()));
247    } else {
248        lines.push(format!("  Kept across boots on a conventional host: add `{REMOVAL_OPTION}` to the options of this filesystem's entry in /etc/fstab (every btrfs entry that shares its filesystem), then reboot."));
249    }
250    return lines;
251}
252
253/// The wording for btrfs.
254fn btrfs_lines(facts: &FilesystemFacts) -> Vec<String> {
255    let mut lines: Vec<String> = Vec::new();
256    if facts.root_is_subvolume {
257        lines.push(format!("{} {} is a btrfs subvolume.", SECTION_MARKER, facts.root.display()));
258    } else {
259        lines.push(format!("{} {} is on btrfs but is an ordinary directory, not a subvolume.", SECTION_MARKER, facts.root.display()));
260        lines.push(String::from("  Making it a subvolume can help with performance: worktrees can then be made from btrfs snapshots, which took a median of 0.35 s to 1.09 s in the design measurements, against 2.1 s to 2.7 s for Git's checkout and 28.35 s with an ignored tree of 72 thousand entries, and share every data block with their source (doc/planning/cli-git-btrfs-worktree.md, section Measurements)."));
261        lines.push(String::from("  This version of the wrapper does not create worktrees from snapshots yet; `git worktree add` still uses Git's checkout. The conversion keeps the original tree until you delete it."));
262        lines.push(String::from("  Stop agent sessions and editors that have their working directory inside the checkout first, then:"));
263        lines.push(String::from("    git cli-git migrate-to-subvolume --dry-run"));
264        lines.push(String::from("    git cli-git migrate-to-subvolume"));
265    }
266    match &facts.mount {
267        Some(mount) if mount.has_removal_option => {
268            lines.push(format!("  The mount {} has `{REMOVAL_OPTION}`: a subvolume worktree is deleted in one call.", mount.mount_point.display()));
269        }
270        Some(mount) => lines.extend(removal_option_lines(facts, mount)),
271        None => lines.push(String::from("  The mount table could not be read, so `user_subvol_rm_allowed` was not checked.")),
272    }
273    return lines;
274}
275
276/// What:
277///  The section text for the facts: lines ending in a line feed, the first beginning with `SECTION_MARKER`.
278/// Why:
279///   Pure wording, so every case is tested without the host.
280///
281/// In TS you'd write (pseudocode):
282/// ```ts
283/// const text = renderSection(facts);
284/// ```
285pub fn render_section(facts: &FilesystemFacts) -> String {
286    let lines: Vec<String> = match facts.kind {
287        FilesystemKind::Btrfs => btrfs_lines(facts),
288        FilesystemKind::Overlayfs => overlay_lines(facts),
289        FilesystemKind::Other => other_lines(facts),
290    };
291    let mut text: String = lines.join("\n");
292    text.push('\n');
293    return text;
294}
295
296/// What:
297///  The section for the repository whose common Git directory is given, read from the running system; empty when that
298///  directory is not a `.git` directory (a bare repository has no main checkout) or its filesystem cannot be identified.
299/// Why:
300///   This is what the command appends to the doctor's standard error. Git chose the common directory, so a worktree,
301///  `-C` and `--git-dir` all lead to the right main checkout without a second guess. A repository the command cannot
302///  place adds no line, because a guess about the wrong directory would mislead.
303///
304/// In TS you'd write (pseudocode):
305/// ```ts
306/// const section = hostSection(commonGitDirectory);
307/// ```
308pub fn host_section(common_directory: &Path) -> String {
309    if common_directory.file_name().is_none_or(|name: &std::ffi::OsStr| { return name != ".git"; }) {
310        return String::new();
311    }
312    let Some(root) = common_directory.parent() else { return String::new() };
313    return gather_facts(root).map(|facts: FilesystemFacts| { return render_section(&facts); }).unwrap_or_default();
314}
315
316/// Filesystem section controls stay out of the release executable.
317#[cfg(test)]
318#[path = "doctor_filesystem_tests.rs"]
319mod tests;