1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
//! What:
//! The state one migration command carries between its steps: the platform, the paths, the journal, the progress
//! sink, the list of files this user may not read, and the helper that runs one tree pass and reports it.
//! Why:
//! The migrate, rollback and remove-old commands share it, so each pass is timed, counted and reported the same way
//! and the unreadable-file list is written the same way after every pass (a kill between passes loses nothing).
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! class Run { platform; layout; journal; interrupt; say(line); async pass(label, source, destination, options) { ... } }
//! ```
use crate::subvolume_error::{Interrupt, MigrateError};
use crate::subvolume_journal::Journal;
use crate::subvolume_lock::relative_lock_paths;
use crate::subvolume_platform::Platform;
use crate::subvolume_preflight::Layout;
use crate::subvolume_sync::{SyncOptions, SyncStats, sync_tree};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::time::Instant;
/// The path, relative to a checkout, of the lock the migration holds during its last pass.
pub const INDEX_LOCK: &str = ".git/index.lock";
/// What:
/// One command's working state.
/// `&'a mut dyn FnMut(&str)` is a borrowed progress callback that receives one line at a time.
/// Why:
/// See the module description.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type Run = { platform: Platform; layout: Layout; journal: Journal; interrupt: Interrupt; say: (line: string) => void; unreadable: Set<string> };
/// ```
pub struct Run<'a> {
/// The operating-system operations.
pub platform: &'a dyn Platform,
/// Every path of the migration.
pub layout: &'a Layout,
/// The journal.
pub journal: Journal,
/// The simulated kill of the controls; never in release use.
pub interrupt: &'a Interrupt,
/// The progress sink.
pub say: &'a mut dyn FnMut(&str),
/// Relative paths of files this user may not read, which stay in the old tree.
pub unreadable: BTreeSet<PathBuf>,
}
impl Run<'_> {
/// What:
/// Start the state: load the journal and the unreadable list left by an earlier run.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const run = await Run.open(platform, layout, interrupt, say);
/// ```
pub fn open<'a>(
platform: &'a dyn Platform,
layout: &'a Layout,
interrupt: &'a Interrupt,
say: &'a mut dyn FnMut(&str),
) -> Result<Run<'a>, MigrateError> {
let journal: Journal = Journal::load(layout.journal_path.as_path())?;
let unreadable: BTreeSet<PathBuf> = match std::fs::read_to_string(&layout.unreadable_path) {
Ok(text) => text.lines().filter(|line: &&str| { return !line.is_empty(); }).map(PathBuf::from).collect(),
// Only a missing list means "no unreadable files were recorded"; a list that exists and cannot be read is
// the safeguard failing, and counting it as empty would let the deletion unlink files that exist only there.
Err(error) if error.kind() == std::io::ErrorKind::NotFound => BTreeSet::new(),
Err(error) => return Err(MigrateError::io("read the list of unreadable files", layout.unreadable_path.as_path(), &error)),
};
return Ok(Run { platform, layout, journal, interrupt, say, unreadable });
}
/// Send one line to the progress sink.
pub fn line(&mut self, text: &str) {
(self.say)(text);
}
/// What:
/// Run one tree pass, report its counts and time, and record the unreadable files it found.
/// Why:
/// The list is written after every pass, so a kill between passes keeps it; the report names the count and the file.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const stats = await run.pass('copy', source, destination, { delete: false, updateOnly: false });
/// ```
pub fn pass(&mut self, label: &str, source: &Path, destination: &Path, delete: bool, update_only: bool) -> Result<SyncStats, MigrateError> {
let stats: SyncStats = self.pass_unrecorded(label, source, destination, delete, update_only)?;
self.record_unreadable(label, &stats)?;
return Ok(stats);
}
/// Record the unreadable files a pass found in the list beside the checkout, and say so.
fn record_unreadable(&mut self, label: &str, stats: &SyncStats) -> Result<(), MigrateError> {
if !stats.unreadable.is_empty() {
self.unreadable.extend(stats.unreadable.iter().cloned());
self.write_unreadable()?;
self.line(format!("{label}: {} file(s) this user may not read stay in the old tree; list: {}", stats.unreadable.len(), self.layout.unreadable_path.display()).as_str());
}
return Ok(());
}
/// What:
/// Run one tree pass and report its counts and time, without recording the unreadable files it found.
/// Why:
/// The rollback's carry-back pass reads the new root, whose unreadable files are not the old tree's: they must
/// not enter the list that guards the deletion of the old tree, and the rollback decides about them itself.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const stats = await run.passUnrecorded('carry back', source, destination, { delete: false, updateOnly: true });
/// ```
pub fn pass_unrecorded(&mut self, label: &str, source: &Path, destination: &Path, delete: bool, update_only: bool) -> Result<SyncStats, MigrateError> {
return self.pass_core(label, source, destination, SyncOptions { delete, update_only, excluded: &[], created_since: None });
}
/// What:
/// Run the update-only pass that carries what was written to the old tree after the catch-up: files newer than
/// their copy, and files the new root lacks that are no older than `since` (seconds after the epoch).
/// Why:
/// Programs that kept a descriptor in the old tree can write there until the person stops them, and the deletion
/// of the old tree must not be the end of those writes. A file the new root lacks that is older than the catch-up
/// was deleted there on purpose and stays deleted. Unreadable files found are recorded like any other pass.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const stats = await run.passLate('late writes', oldTree, root, catchupSeconds);
/// ```
pub fn pass_late(&mut self, label: &str, source: &Path, destination: &Path, since: i64) -> Result<SyncStats, MigrateError> {
let stats: SyncStats = self.pass_core(label, source, destination, SyncOptions { delete: false, update_only: true, excluded: &[], created_since: Some(since) })?;
self.record_unreadable(label, &stats)?;
for path in &stats.copied_sample {
self.line(format!("{label}: carried {}", path.display()).as_str());
}
return Ok(stats);
}
/// The pass itself: the lock files of both trees are added to the excluded paths, and the counts are reported.
fn pass_core(&mut self, label: &str, source: &Path, destination: &Path, requested: SyncOptions<'_>) -> Result<SyncStats, MigrateError> {
let mut excluded: Vec<PathBuf> = relative_lock_paths(source);
excluded.extend(relative_lock_paths(destination));
excluded.sort();
excluded.dedup();
let started: Instant = Instant::now();
let stats: SyncStats = sync_tree(self.platform, source, destination, SyncOptions { excluded: excluded.as_slice(), ..requested }, self.interrupt)?;
self.line(
format!(
"{label}: {} entries, {} files copied ({} cloned), {} hard-linked, {} symlinks, {} unchanged, {} deleted, {} vanished, {:.2} s",
stats.entries, stats.files_copied, stats.cloned, stats.hard_linked, stats.symlinks, stats.unchanged, stats.deleted, stats.vanished,
started.elapsed().as_secs_f64()
)
.as_str(),
);
for path in &stats.special_skipped {
self.line(format!("{label}: not recreated (a socket or device node): {}", path.display()).as_str());
}
if stats.losses.owner > 0 || stats.losses.extended_attributes > 0 || stats.losses.mode > 0 || stats.losses.times > 0 {
self.line(
format!(
"{label}: could not set the owner of {} entries, the permission bits of {}, the times of {}, and {} extended attributes (not running as the owner or as root)",
stats.losses.owner, stats.losses.mode, stats.losses.times, stats.losses.extended_attributes
)
.as_str(),
);
}
for path in &stats.conflicts {
self.line(format!("{label}: kept the newer or differently typed entry {} in the destination; it was not replaced", path.display()).as_str());
}
if stats.left_out > 0 {
self.line(format!("{label}: {} entr(ies) absent from the destination were left out because they predate the catch-up", stats.left_out).as_str());
}
if stats.recopied > 0 {
self.line(format!("{label}: {} file(s) changed while they were copied and were copied again", stats.recopied).as_str());
}
return Ok(stats);
}
/// Write the unreadable-file list beside the checkout.
fn write_unreadable(&self) -> Result<(), MigrateError> {
let mut text: String = String::new();
for path in &self.unreadable {
text.push_str(path.to_string_lossy().as_ref());
text.push('\n');
}
return std::fs::write(&self.layout.unreadable_path, text).map_err(|error: std::io::Error| {
return MigrateError::io("write the list of unreadable files", self.layout.unreadable_path.as_path(), &error);
});
}
/// Stop here when the control asked for a simulated kill at this point.
pub fn checkpoint(&self, point: &str) -> Result<(), MigrateError> {
return self.interrupt.check(point);
}
}