Skip to main content

git_policy_cli/subvolume/
subvolume_rollback.rs

1//! What:
2//!  Undo a migration (`--rollback`) and delete the old tree after a finished one (`--remove-old`), plus the removal of
3//!  a subvolume tree that both depend on.
4//! Why:
5//!  Rollback carries back what was written to the new root since the exchange (files newer there win), swaps the trees
6//!  again, rewrites the restored indexes' stat fields (the carried-back index describes the new tree's inodes), and
7//!  deletes the new subvolume deepest nested subvolume first, because the kernel refuses to destroy a subvolume that
8//!  holds another (`ENOTEMPTY`). Where the mount does not allow an unprivileged destroy, an emptied subvolume is
9//!  removed with `rmdir`, which the kernel allows for an empty subvolume root. The old tree is an ordinary directory,
10//!  so deleting it is a file walk, never a subvolume destroy.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! await rollback(run); await removeOldTree(run, { discardUnreadable });
15//! ```
16
17use crate::subvolume_error::MigrateError;
18use crate::subvolume_index_files::{IndexLock, refresh_all_indexes};
19use crate::subvolume_lock::{release_all, token_of};
20use crate::subvolume_preflight::{Refusal, remove_old_refusals, rollback_refusals};
21use crate::subvolume_remove::remove_tree;
22use crate::subvolume_run::Run;
23use crate::subvolume_sync::SyncStats;
24use std::path::{Path, PathBuf};
25
26/// Remove a file, treating "already gone" as done.
27fn remove_if_present(path: &Path) -> Result<(), MigrateError> {
28    return match std::fs::remove_file(path) {
29        Ok(()) => Ok(()),
30        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
31        Err(error) => Err(MigrateError::io("remove", path, &error)),
32    };
33}
34
35/// What:
36///  Every directory inside `top` (not `top` itself) that is a subvolume root, deepest first.
37/// Why:
38///   Destroying them in this order never meets a subvolume that still holds another.
39///
40/// In TS you'd write (pseudocode):
41/// ```ts
42/// const nested = await nestedSubvolumes(top); // deepest first
43/// ```
44fn nested_subvolumes(run: &Run<'_>, top: &Path) -> Vec<PathBuf> {
45    let mut found: Vec<PathBuf> = Vec::new();
46    let mut stack: Vec<PathBuf> = vec![top.to_path_buf()];
47    while let Some(directory) = stack.pop() {
48        let Ok(listing) = std::fs::read_dir(&directory) else { continue };
49        for item in listing.flatten() {
50            let Ok(file_type) = item.file_type() else { continue };
51            if !file_type.is_dir() {
52                continue;
53            }
54            if run.platform.is_subvolume_root(item.path().as_path()).unwrap_or(false) {
55                found.push(item.path());
56            }
57            stack.push(item.path());
58        }
59    }
60    found.sort_by_key(|path: &PathBuf| { return std::cmp::Reverse(path.components().count()); });
61    return found;
62}
63
64/// What:
65///  Remove one subvolume that holds no other: by the destroy call where the mount allows it, else by emptying it and
66///  `rmdir`.
67/// Why:
68///   Both end with the directory gone. A destroy that fails for any reason falls through to the emptying route, whose
69///  own failure is reported.
70///
71/// In TS you'd write (pseudocode):
72/// ```ts
73/// await removeOneSubvolume(run, path);
74/// ```
75fn remove_one_subvolume(run: &mut Run<'_>, path: &Path) -> Result<(), MigrateError> {
76    if run.platform.removal_allowed(path) && run.platform.destroy_subvolume(path).is_ok() {
77        return Ok(());
78    }
79    remove_tree(path).map_err(|error: std::io::Error| { return MigrateError::io("empty and remove the subvolume", path, &error); })?;
80    return Ok(());
81}
82
83/// What:
84///  Remove a subvolume and every subvolume nested in it, deepest first.
85/// Why:
86///   See the module description.
87///
88/// In TS you'd write (pseudocode):
89/// ```ts
90/// await removeSubvolumeTree(run, path);
91/// ```
92pub fn remove_subvolume_tree(run: &mut Run<'_>, path: &Path) -> Result<(), MigrateError> {
93    for nested in nested_subvolumes(run, path) {
94        remove_one_subvolume(run, nested.as_path())?;
95    }
96    return remove_one_subvolume(run, path);
97}
98
99/// What:
100///  The swap of the rollback: carry back what is newer in the new root, exchange the trees once, rename the new
101///  subvolume to its own name, and record it.
102///  `Ok(Some(refusals))` means nothing was changed.
103/// Why:
104///   The exchange cannot be repeated: after it the checkout path holds the original directory, and a second exchange
105///  would put the new subvolume back and then delete the original. The journal step `rollback-swap` is recorded before
106///  the call, and a rerun reads the checkout path to learn whether the call took effect: a subvolume root there means
107///  it did not, anything else means it did and only the rename and the record are left.
108///
109/// In TS you'd write (pseudocode):
110/// ```ts
111/// const refused = await swapBack(run);
112/// ```
113fn swap_back(run: &mut Run<'_>, discard_unreadable: bool) -> Result<Option<Vec<Refusal>>, MigrateError> {
114    let root: PathBuf = run.layout.root.clone();
115    let new_path: PathBuf = run.layout.new_path.clone();
116    let old_path: PathBuf = run.layout.old_path.clone();
117    let swapped: bool = run.journal.has_begun("rollback-swap") && !run.platform.is_subvolume_root(root.as_path()).unwrap_or(false);
118    if !swapped {
119        if !old_path.exists() {
120            return Ok(Some(vec![Refusal {
121                cause: format!("the migration is recorded as exchanged, but the old tree {} is missing, so there is nothing to restore", old_path.display()),
122                remedy: String::from("If the old tree was moved, move it back to that path and run the command again; if it was deleted, the checkout cannot be rolled back and the new subvolume is the only copy."),
123            }]));
124        }
125        let carried: SyncStats = run.pass_unrecorded("carry back (files newer in the new root)", root.as_path(), old_path.as_path(), false, true)?;
126        if !carried.unreadable.is_empty() && !discard_unreadable {
127            let shown: Vec<String> = carried.unreadable.iter().take(10).map(|path: &PathBuf| { return path.display().to_string(); }).collect();
128            return Ok(Some(vec![Refusal {
129                cause: format!("{} file(s) that this user may not read exist only in the new subvolume {} and cannot be carried back: {}", carried.unreadable.len(), root.display(), shown.join(", ")),
130                remedy: String::from("Copy them out with the privilege they need (for example `sudo cp --archive`), or pass --discard-unreadable together with --rollback to delete them with the new subvolume."),
131            }]));
132        }
133        run.checkpoint("rollback-before-exchange")?;
134        run.journal.mark("rollback-swap", "started")?;
135        run.platform.exchange(root.as_path(), old_path.as_path())?;
136        run.checkpoint("rollback-after-swap")?;
137    }
138    if old_path.exists() && !new_path.exists() {
139        std::fs::rename(&old_path, &new_path).map_err(|error: std::io::Error| { return MigrateError::io("rename", old_path.as_path(), &error); })?;
140    }
141    run.checkpoint("rollback-after-rename")?;
142    run.journal.mark("exchange", "rolled-back")?;
143    run.checkpoint("rollback-after-exchange")?;
144    return Ok(None);
145}
146
147/// What:
148///  The rollback.
149///  `Ok(None)` means it ran; `Ok(Some(refusals))` means nothing was changed.
150/// Why:
151///   Three states can be undone: before the exchange (delete the new subvolume and the lock), after it (carry back,
152///  swap again, delete the new subvolume), and in between (finish the bookkeeping, then the second case).
153///
154/// In TS you'd write (pseudocode):
155/// ```ts
156/// const refused = await rollback(run);
157/// ```
158pub fn rollback(run: &mut Run<'_>, discard_unreadable: bool) -> Result<Option<Vec<Refusal>>, MigrateError> {
159    let refusals: Vec<Refusal> = rollback_refusals(run.layout, &run.journal);
160    if !refusals.is_empty() {
161        return Ok(Some(refusals));
162    }
163    let root: PathBuf = run.layout.root.clone();
164    let new_path: PathBuf = run.layout.new_path.clone();
165    let old_path: PathBuf = run.layout.old_path.clone();
166    if run.journal.has_begun("exchange") && !run.journal.is_done("exchange") && run.platform.is_subvolume_root(root.as_path()).unwrap_or(false) {
167        if new_path.exists() && !old_path.exists() {
168            std::fs::rename(&new_path, &old_path).map_err(|error: std::io::Error| { return MigrateError::io("rename", new_path.as_path(), &error); })?;
169        }
170        run.journal.mark("exchange", "done")?;
171    }
172    if run.journal.is_done("exchange")
173        && let Some(blocked) = swap_back(run, discard_unreadable)?
174    {
175        return Ok(Some(blocked));
176    }
177    // The restored tree is the one the migration locked: a stop before the exchange leaves its locks in the checkout, and
178    // a stop between the exchange and the removal of the locks from the old tree restores a tree that still holds them.
179    // Git would refuse every index write while they stay. Only locks that hold this migration's token are removed.
180    let token: String = token_of(&run.journal);
181    release_all(root.as_path(), token.as_str())?;
182    // The carried-back index describes the new tree's inodes; this runs after a stop at any step above, once.
183    if run.journal.state("exchange") == Some("rolled-back") && !run.journal.is_done("rollback-indexes") {
184        let mut lines: Vec<String> = Vec::new();
185        refresh_all_indexes(root.as_path(), IndexLock::Acquire, std::slice::from_ref(&new_path), &mut |text: &str| { lines.push(text.to_string()); });
186        for text in &lines {
187            run.line(text.as_str());
188        }
189        run.journal.mark("rollback-indexes", "done")?;
190    }
191    if new_path.exists() {
192        run.line(format!("deleting the new subvolume {} (nested subvolumes first)", new_path.display()).as_str());
193        remove_subvolume_tree(run, new_path.as_path())?;
194    }
195    run.journal.delete()?;
196    remove_if_present(run.layout.unreadable_path.as_path())?;
197    run.line(format!("rolled back: {} is the original directory again, and the migration journal is deleted.", root.display()).as_str());
198    return Ok(None);
199}
200
201/// What:
202///  Carry into the checkout what was written to the old tree after the catch-up finished.
203/// Why:
204///   A process that kept a descriptor in the old tree can write there until it is stopped, and the catch-up that was
205///  recorded as done cannot see those writes. The pass is update-only: it copies a file that is newer than the
206///  checkout's, and a file the checkout lacks only when it is no older than the catch-up (older ones were deleted from
207///  the checkout on purpose). A journal without the catch-up's time carries only the newer files.
208///
209/// In TS you'd write (pseudocode):
210/// ```ts
211/// await carryLateWrites(run);
212/// ```
213fn carry_late_writes(run: &mut Run<'_>) -> Result<(), MigrateError> {
214    let (root, old_path): (PathBuf, PathBuf) = (run.layout.root.clone(), run.layout.old_path.clone());
215    let since: i64 = run.journal.seconds_of("catchup").unwrap_or(i64::MAX);
216    let stats: SyncStats = run.pass_late("late writes (files written to the old tree after the catch-up)", old_path.as_path(), root.as_path(), since)?;
217    if stats.files_copied > 0 || !stats.conflicts.is_empty() {
218        run.line(format!("{} file(s) written to the old tree after the catch-up were carried into {}", stats.files_copied, root.display()).as_str());
219    }
220    return Ok(());
221}
222
223/// What:
224///  Delete the old tree of a finished migration.
225///  `Ok(None)` means it ran; `Ok(Some(refusals))` means nothing was changed.
226/// Why:
227///   The files this user could not read exist only there, so a non-empty list refuses unless the person discards them
228///  explicitly. The deletion (`remove_tree`) unlinks entries, makes directories the user owns writable on the way, and never follows symbolic links.
229///
230/// In TS you'd write (pseudocode):
231/// ```ts
232/// const refused = await removeOldTree(run, discardUnreadable);
233/// ```
234pub fn remove_old_tree(run: &mut Run<'_>, discard_unreadable: bool) -> Result<Option<Vec<Refusal>>, MigrateError> {
235    let mut refusals: Vec<Refusal> = remove_old_refusals(run.layout, &run.journal);
236    let old_path: PathBuf = run.layout.old_path.clone();
237    if refusals.is_empty() {
238        let metadata: std::fs::Metadata = std::fs::symlink_metadata(&old_path).map_err(|error: std::io::Error| { return MigrateError::io("examine", old_path.as_path(), &error); })?;
239        if metadata.file_type().is_symlink() {
240            return Ok(Some(vec![Refusal {
241                cause: format!("{} is a symbolic link, and the command deletes only a directory it moved itself", old_path.display()),
242                remedy: String::from("Inspect it and delete it by hand."),
243            }]));
244        }
245        carry_late_writes(run)?;
246    }
247    if refusals.is_empty() && !run.unreadable.is_empty() && !discard_unreadable {
248        refusals.push(Refusal {
249            cause: format!("{} file(s) that this user may not read exist only in {} (listed in {})", run.unreadable.len(), run.layout.old_path.display(), run.layout.unreadable_path.display()),
250            remedy: String::from("Copy them out with the privilege they need (for example `sudo cp --archive`), or pass --discard-unreadable together with --remove-old to delete them with the old tree."),
251        });
252    }
253    if !refusals.is_empty() {
254        return Ok(Some(refusals));
255    }
256    run.line(format!("deleting {} (every file is unlinked; this can take minutes)", old_path.display()).as_str());
257    remove_tree(&old_path).map_err(|error: std::io::Error| { return MigrateError::io("delete", old_path.as_path(), &error); })?;
258    run.journal.delete()?;
259    remove_if_present(run.layout.unreadable_path.as_path())?;
260    run.line("the old tree is deleted and the migration journal is removed.");
261    return Ok(None);
262}
263
264/// Rollback controls stay out of the release executable.
265#[cfg(test)]
266#[path = "subvolume_rollback_tests.rs"]
267mod tests;