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
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
//! What:
//! The journal of one conversion of a worktree into a subvolume.
//! Why:
//! The journal is the only proof that the converting subvolume and the index lock are this command's, and it names the
//! identity of the old root and of the subvolume it created, which is what every later phase checks before it reuses,
//! exchanges, destroys or deletes anything. The journal is written through exclusively created files and never replaces
//! a file it did not make. One command at a time owns a conversion through a numbered claim file
//! (`single_owner_claim.rs`) taken before the journal is read, so no kernel lock is held.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const claim = await takeClaim({ directory: admin, prefix: CLAIM_PREFIX }); // busy when another command owns it
//! const journal = await Journal.load(admin);
//! await journal.save(admin); // exclusive temporary, then rename
//! ```
use super::fs_beneath::ObjectId;
use super::subvolume_error::MigrateError;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::io::AsRawFd;
use std::path::{Path, PathBuf};
/// The name of the journal inside the worktree's administrative directory.
pub const JOURNAL_NAME: &str = "cli-git-convert";
/// The name of the kernel lock file that releases up to 0.5.20 left in the administrative directory; this release only probes for
/// it and removes it when nothing holds it.
pub const LOCK_NAME: &str = "cli-git-convert.lock";
/// The prefix of the numbered claim files that give one command at a time the conversion of a worktree.
pub const CLAIM_PREFIX: &str = "cli-git-convert.owner";
/// The most bytes of a journal or a small registration file that are read.
const SMALL_FILE_LIMIT: u64 = 1 << 20;
/// The first line of every journal.
const JOURNAL_HEADER: &str = "cli-git-convert v1";
/// What:
/// The phase a conversion has reached, in order.
/// Why:
/// A rerun reads it to skip the finished phases and to decide whether an abandon is still possible.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type Phase = 'started' | 'creating' | 'synced' | 'exchanged' | 'refreshed' | 'removed';
/// ```
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum Phase {
/// The journal exists; nothing else (Git's `index.lock` is taken only for the proving pass, and a release up to 0.5.20 took it
/// here, which a rerun accepts).
Started,
/// The converting subvolume is being created, or exists.
Creating,
/// The copy was made and proved equal to the original; provisional until the swap, because a writer may act after it.
Synced,
/// The path names the subvolume; the old tree sits at the converting name.
Exchanged,
/// The index stat fields describe the files of the subvolume.
Refreshed,
/// The old tree is gone; only the journal remains.
Removed,
}
impl Phase {
/// The name written to the journal.
fn name(self) -> &'static str {
return match self {
Phase::Started => "started",
Phase::Creating => "creating",
Phase::Synced => "synced",
Phase::Exchanged => "exchanged",
Phase::Refreshed => "refreshed",
Phase::Removed => "removed",
};
}
/// The phase a journal line names.
fn parse(text: &str) -> Option<Phase> {
return [Phase::Started, Phase::Creating, Phase::Synced, Phase::Exchanged, Phase::Refreshed, Phase::Removed]
.into_iter()
.find(|phase: &Phase| { return phase.name() == text; });
}
}
/// What:
/// The journal of one conversion, kept in the worktree's administrative directory.
/// Why:
/// See the module description.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type ConvertJournal = { phase: Phase; token: string; path: string; oldIdentity: ObjectId; gitFile: Uint8Array; newIdentity?: ObjectId };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Journal {
/// The phase reached.
pub phase: Phase,
/// The token the index lock holds.
pub token: String,
/// The worktree path as raw bytes.
pub path: Vec<u8>,
/// The identity of the old root: device, inode and, where the filesystem gives one, the birth time, which tells the recorded
/// directory from another that later got the same inode number.
pub old_identity: ObjectId,
/// The exact bytes of the `.git` file.
pub git_file: Vec<u8>,
/// The identity of the subvolume this command created, recorded right after the creation; absent until then, so a
/// directory at the converting name that the journal has no identity for is foreign.
pub new_identity: Option<ObjectId>,
}
/// The lowercase hexadecimal form of some bytes.
fn hex(bytes: &[u8]) -> String {
return bytes.iter().map(|byte: &u8| { return format!("{byte:02x}"); }).collect();
}
/// The bytes a hexadecimal string names.
fn unhex(text: &str) -> Option<Vec<u8>> {
if text.len() % 2 != 0 {
return None;
}
return (0..text.len()).step_by(2).map(|at: usize| { return u8::from_str_radix(text.get(at..at + 2)?, 16).ok(); }).collect();
}
/// A name for a temporary file that no other file has: this process and the clock make it, and the creation is exclusive.
fn temporary_name() -> String {
let nanos: u128 = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |elapsed: std::time::Duration| { return elapsed.as_nanos(); });
return format!("{JOURNAL_NAME}.tmp.{}.{nanos}", std::process::id());
}
impl Journal {
/// The journal text.
fn encode(&self) -> String {
return format!(
"{JOURNAL_HEADER}\nphase={}\ntoken={}\npath={}\nold_identity={}\ngit_file={}\nnew_identity={}\n",
self.phase.name(),
self.token,
hex(self.path.as_slice()),
self.old_identity.encode(),
hex(self.git_file.as_slice()),
self.new_identity.map_or_else(|| { return String::from("-"); }, |identity: ObjectId| { return identity.encode(); })
);
}
/// The journal a text describes.
fn decode(text: &str) -> Result<Journal, MigrateError> {
let bad = || { return MigrateError::Journal(String::from("the conversion journal has a shape this command does not know; nothing was changed")); };
let mut lines = text.lines();
if lines.next() != Some(JOURNAL_HEADER) {
return Err(bad());
}
let field = |name: &str| -> Option<String> { return text.lines().find_map(|line: &str| { return line.strip_prefix(name).and_then(|rest: &str| { return rest.strip_prefix('='); }).map(String::from); }); };
let new_identity: Option<ObjectId> = match field("new_identity").as_deref() {
None | Some("-") => None,
Some(encoded) => Some(ObjectId::decode(encoded).ok_or_else(bad)?),
};
// A journal written before the birth time was recorded names the old root by device and inode alone.
let old_identity: ObjectId = match field("old_identity") {
Some(encoded) => ObjectId::decode(encoded.as_str()).ok_or_else(bad)?,
None => ObjectId {
device: field("old_device").and_then(|digits: String| { return digits.parse().ok(); }).ok_or_else(bad)?,
inode: field("old_inode").and_then(|digits: String| { return digits.parse().ok(); }).ok_or_else(bad)?,
birth: None,
},
};
return Ok(Journal {
phase: Phase::parse(field("phase").ok_or_else(bad)?.as_str()).ok_or_else(bad)?,
token: field("token").ok_or_else(bad)?,
path: unhex(field("path").ok_or_else(bad)?.as_str()).ok_or_else(bad)?,
old_identity,
git_file: unhex(field("git_file").ok_or_else(bad)?.as_str()).ok_or_else(bad)?,
new_identity,
});
}
/// Write the journal into a file made for it alone: created exclusively, so nothing that is there is truncated or followed.
fn write_temporary(&self, admin: &Path) -> std::io::Result<PathBuf> {
let temporary: PathBuf = admin.join(temporary_name());
let mut file: std::fs::File = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&temporary)?;
let written: std::io::Result<()> = file.write_all(self.encode().as_bytes()).and_then(|()| { return file.sync_all(); });
if let Err(error) = written {
let _removed: std::io::Result<()> = std::fs::remove_file(&temporary);
return Err(error);
}
return Ok(temporary);
}
/// Replace the journal with this one: written beside it, synchronized, renamed into place.
pub fn save(&self, admin: &Path) -> Result<(), MigrateError> {
let target: PathBuf = admin.join(JOURNAL_NAME);
let written: std::io::Result<()> = (|| {
let temporary: PathBuf = self.write_temporary(admin)?;
if let Err(error) = std::fs::rename(&temporary, &target) {
let _removed: std::io::Result<()> = std::fs::remove_file(&temporary);
return Err(error);
}
return std::fs::File::open(admin).and_then(|directory: std::fs::File| { return directory.sync_all(); });
})();
return written.map_err(|error: std::io::Error| { return MigrateError::io("write the conversion journal", target.as_path(), &error); });
}
/// Make this the journal of a conversion that has none: it fails when any journal exists, so a journal another command
/// made is never replaced.
pub fn create_new(&self, admin: &Path) -> Result<(), MigrateError> {
let target: PathBuf = admin.join(JOURNAL_NAME);
let written: std::io::Result<()> = (|| {
let temporary: PathBuf = self.write_temporary(admin)?;
let linked: std::io::Result<()> = std::fs::hard_link(&temporary, &target);
let _removed: std::io::Result<()> = std::fs::remove_file(&temporary);
linked?;
return std::fs::File::open(admin).and_then(|directory: std::fs::File| { return directory.sync_all(); });
})();
return written.map_err(|error: std::io::Error| { return MigrateError::io("create the conversion journal", target.as_path(), &error); });
}
/// The journal of an administrative directory, if one exists.
pub fn load(admin: &Path) -> Result<Option<Journal>, MigrateError> {
let path: PathBuf = admin.join(JOURNAL_NAME);
match read_plain_file(path.as_path(), SMALL_FILE_LIMIT) {
Ok(bytes) => return Journal::decode(String::from_utf8_lossy(bytes.as_slice()).as_ref()).map(Some),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(MigrateError::io("read the conversion journal", path.as_path(), &error)),
}
}
}
/// What:
/// The bytes of the plain file at `path`, opened without blocking and without following a link, refused when it is anything
/// else or larger than `limit`.
/// Why:
/// Opening a named pipe for reading waits for a writer, and a command that waits while it owns the conversion blocks every
/// other command that wants it; the type is read from the open handle, and the read is bounded.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function readPlainFile(path: string, limit: number): Buffer; // throws
/// ```
pub fn read_plain_file(path: &Path, limit: u64) -> std::io::Result<Vec<u8>> {
let file: std::fs::File = std::fs::OpenOptions::new().read(true).custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW | libc::O_CLOEXEC).open(path)?;
if !file.metadata()?.file_type().is_file() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "it is not a plain file (a named pipe, a device or a directory)"));
}
let mut bytes: Vec<u8> = Vec::new();
std::io::Read::read_to_end(&mut std::io::Read::take(&file, limit + 1), &mut bytes)?;
if bytes.len() as u64 > limit {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "it is larger than this command reads"));
}
return Ok(bytes);
}
/// The text of the plain file at `path`, read like `read_plain_file`.
pub fn read_plain_text(path: &Path) -> std::io::Result<String> {
return String::from_utf8(read_plain_file(path, SMALL_FILE_LIMIT)?).map_err(|_error| { return std::io::Error::new(std::io::ErrorKind::InvalidData, "it is not text"); });
}
/// Whether `found` is the object the journal recorded as `recorded`: the same device and inode and, when a birth time was
/// recorded, the same birth time.
pub fn is_recorded_object(recorded: &ObjectId, found: &ObjectId) -> bool {
return recorded.device == found.device && recorded.inode == found.inode && (recorded.birth.is_none() || recorded.birth == found.birth);
}
/// What:
/// Whether a command of an older release still holds the conversion's kernel lock on `admin`, retiring the lock file when
/// none does.
/// Why:
/// Releases up to 0.5.20 serialized conversions with an advisory kernel lock on `cli-git-convert.lock` and left the file
/// in place. This release serializes with a claim file instead and takes no kernel lock, but an older command may still be
/// converting the worktree, and two commands must never convert one worktree at once. The probe asks without waiting and
/// gives the lock straight back; a file nobody holds is removed, so the leftover disappears after the upgrade. A file that
/// is absent costs nothing, and a file that is not a plain file is left where it is.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const heldByOlderRelease = await retireLegacyLock(admin); // false when absent, free (and now removed) or not a plain file
/// ```
pub fn legacy_lock_is_held(admin: &Path) -> Result<bool, MigrateError> {
let path: PathBuf = admin.join(LOCK_NAME);
let file: std::fs::File = match std::fs::OpenOptions::new().read(true).write(true).custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW | libc::O_CLOEXEC).open(&path) {
Ok(opened) => opened,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(MigrateError::io("open the conversion lock an older release left", path.as_path(), &error)),
};
let plain: bool = file.metadata().map_err(|error: std::io::Error| { return MigrateError::io("examine the conversion lock an older release left", path.as_path(), &error); })?.file_type().is_file();
if !plain {
return Ok(false);
}
// SAFETY: `flock` takes an open descriptor, which `file` keeps open for the call.
let result: libc::c_int = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if result != 0 {
let error: std::io::Error = std::io::Error::last_os_error();
if error.kind() == std::io::ErrorKind::WouldBlock {
return Ok(true);
}
return Err(MigrateError::io("probe the conversion lock an older release left", path.as_path(), &error));
}
// Removed while the probe still holds it, so no older command can take the name between the probe and the removal.
return match std::fs::remove_file(&path) {
Ok(()) => Ok(false),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(MigrateError::io("remove the conversion lock an older release left", path.as_path(), &error)),
};
}