Skip to main content

git_policy_cli/worktree/
worktree_trash_remove.rs

1//! What:
2//!  Remove a directory tree beneath a held parent directory: every directory is opened by one plain name below the handle of
3//!  its parent without following a link, and every entry is unlinked from the handle of the directory that holds it.
4//! Why:
5//!  A path string is resolved again by every call that takes it, so a directory replaced by a link between a check and the
6//!  removal redirects the removal. Here no call resolves a path: a link, a mount point or a directory on another device is
7//!  an error that names it, and the identity of the top directory is compared with the recorded one on the object that is
8//!  opened, so a replacement is never emptied. A directory the owner cannot write to is given the owner's permission bits
9//!  through its descriptor on the way down, as `subvolume_remove.rs` does for the old tree of a conversion.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! await removeTreeBeneath({ label, parent, name, expected }); // throws a reason; the entry is left as it is when it is not the expected object
14//! ```
15
16use crate::fs_beneath::{ObjectId, device_of, handle_path, identity_at, identity_of, open_directory_at, remove_at};
17use std::ffi::{OsStr, OsString};
18use std::fs::File;
19use std::os::unix::fs::PermissionsExt;
20use std::path::{Path, PathBuf};
21
22/// Tag used for diagnostics from this module.
23const TAG: &str = "worktreeTrashRemove";
24
25/// What a removal found.
26#[derive(Clone, Copy, Debug, Eq, PartialEq)]
27pub enum Removal {
28    /// The tree was removed.
29    Removed,
30    /// Nothing was at the name.
31    Absent,
32}
33
34/// How a message names an entry: built only when a message is made, so a removal that succeeds builds no path.
35type Describe<'a> = &'a dyn Fn() -> PathBuf;
36
37/// One directory of the walk: its handle, its name in the directory above, and the entries still to remove.
38struct Frame {
39    /// The directory, held open so every call below it names a plain entry of this object.
40    dir: File,
41    /// Its name in the directory above.
42    name: OsString,
43    /// The entries not yet removed, each with whether the listing says it is a directory.
44    entries: Vec<(OsString, bool)>,
45}
46
47/// Whether an error says the entry is already gone.
48fn is_gone(error: &std::io::Error) -> bool {
49    return error.kind() == std::io::ErrorKind::NotFound;
50}
51
52/// The names in an open directory, each with whether it is a directory (a link is not one).
53fn list_entries(dir: &File, shown: Describe<'_>) -> Result<Vec<(OsString, bool)>, String> {
54    let listing = std::fs::read_dir(handle_path(dir)).map_err(|error: std::io::Error| { return format!("{} cannot be listed ({error})", shown().display()); })?;
55    let mut entries: Vec<(OsString, bool)> = Vec::new();
56    for listed in listing {
57        let entry: std::fs::DirEntry = listed.map_err(|error: std::io::Error| { return format!("{} cannot be listed ({error})", shown().display()); })?;
58        let is_directory: bool = entry.file_type().map_err(|error: std::io::Error| { return format!("{} cannot be examined ({error})", shown().join(entry.file_name()).display()); })?.is_dir();
59        entries.push((entry.file_name(), is_directory));
60    }
61    return Ok(entries);
62}
63
64/// Give the owner read, write and search permission on an open directory; a directory it cannot be given them on keeps its
65///  mode and the removal that follows reports the operating system's refusal.
66fn make_writable(dir: &File, shown: Describe<'_>) {
67    let Ok(metadata) = dir.metadata() else { return };
68    let mode: u32 = metadata.permissions().mode();
69    if mode & 0o700 == 0o700 {
70        return;
71    }
72    if let Err(error) = dir.set_permissions(std::fs::Permissions::from_mode(mode | 0o700)) {
73        crate::diagnostic_log::debug(TAG, format!("{} keeps its mode ({error})", shown().display()).as_str());
74    }
75}
76
77/// Open the directory `name` below `parent` and describe it as a frame; the reason is the error.
78fn enter(parent: &File, name: &OsStr, device: u64, shown: Describe<'_>) -> Result<Frame, String> {
79    let dir: File = open_directory_at(parent, name).map_err(|error: std::io::Error| { return format!("{} is not a plain directory that can be entered, so it is left alone ({error})", shown().display()); })?;
80    let found: u64 = device_of(&dir).map_err(|error: std::io::Error| { return format!("{} cannot be examined ({error})", shown().display()); })?;
81    if found != device {
82        return Err(format!("{} is on another device, so it is left alone", shown().display()));
83    }
84    make_writable(&dir, shown);
85    let entries: Vec<(OsString, bool)> = list_entries(&dir, shown)?;
86    return Ok(Frame { dir, name: name.to_os_string(), entries });
87}
88
89/// What:
90///  Remove the directory `name` below `parent` with everything in it; `label` is the path of `parent`, used only in messages.
91/// Why:
92///   With `expected` set, the directory is removed only when it is that object: the identity is compared by name before the
93///  directory is opened and again on the opened object, so a replacement made between the two is left alone. The top
94///  directory must be on the device of `parent`. Nothing is followed and nothing outside the directory is touched. An entry
95///  that vanishes meanwhile counts as removed. Directories are entered with a work stack, so depth costs no recursion.
96///
97/// In TS you'd write (pseudocode):
98/// ```ts
99/// const removal = await removeTreeBeneath({ label, parent, name, expected }); // 'removed' | 'absent'
100/// ```
101pub fn remove_tree_beneath(label: &Path, parent: &File, name: &OsStr, expected: Option<ObjectId>) -> Result<Removal, String> {
102    let top: PathBuf = label.join(name);
103    let device: u64 = device_of(parent).map_err(|error: std::io::Error| { return format!("{} cannot be examined ({error})", label.display()); })?;
104    match identity_at(parent, name) {
105        Ok(found) => {
106            if let Some(wanted) = expected && found != wanted {
107                return Err(format!("{} is not the object that was set aside ({} instead of {}), so it is left alone", top.display(), found.encode(), wanted.encode()));
108            }
109        }
110        Err(error) if is_gone(&error) => return Ok(Removal::Absent),
111        Err(error) => return Err(format!("{} cannot be examined ({error})", top.display())),
112    }
113    let first: Frame = enter(parent, name, device, &|| { return top.clone(); })?;
114    if let Some(wanted) = expected {
115        let opened: ObjectId = identity_of(&first.dir).map_err(|error: std::io::Error| { return format!("{} cannot be examined ({error})", top.display()); })?;
116        if opened != wanted {
117            return Err(format!("{} is not the object that was set aside ({} instead of {}), so it is left alone", top.display(), opened.encode(), wanted.encode()));
118        }
119    }
120    let mut stack: Vec<Frame> = vec![first];
121    // The names from the top directory down to the frame being worked on, for messages.
122    let mut shown: Vec<OsString> = Vec::new();
123    // The path of the frame being worked on, from the names kept in `shown`.
124    let here = |names: &[OsString]| -> PathBuf { return names.iter().fold(top.clone(), |path: PathBuf, part: &OsString| { return path.join(part); }); };
125    loop {
126        let Some(frame) = stack.last_mut() else { break };
127        match frame.entries.pop() {
128            Some((entry, true)) => {
129                let next: Frame = enter(&frame.dir, entry.as_os_str(), device, &|| { return here(shown.as_slice()).join(&entry); })?;
130                shown.push(entry);
131                stack.push(next);
132            }
133            Some((entry, false)) => match remove_at(&frame.dir, entry.as_os_str(), false) {
134                Ok(()) => {}
135                Err(error) if is_gone(&error) => {}
136                Err(error) => return Err(format!("{} cannot be removed ({error})", here(shown.as_slice()).join(&entry).display())),
137            },
138            None => {
139                let Some(done) = stack.pop() else { break };
140                let holder: &File = stack.last().map_or(parent, |above: &Frame| { return &above.dir; });
141                match remove_at(holder, done.name.as_os_str(), true) {
142                    Ok(()) => {}
143                    Err(error) if is_gone(&error) => {}
144                    Err(error) => return Err(format!("{} cannot be removed ({error})", here(shown.as_slice()).display())),
145                }
146                shown.pop();
147            }
148        }
149    }
150    return Ok(Removal::Removed);
151}
152
153/// Tree removal controls stay out of the release executable.
154#[cfg(test)]
155#[path = "worktree_trash_remove_tests.rs"]
156mod tests;