Skip to main content

git_policy_cli/worktree/
worktree_convert_journal.rs

1//! What:
2//!  The journal of one conversion of a worktree into a subvolume.
3//! Why:
4//!  The journal is the only proof that the converting subvolume and the index lock are this command's, and it names the
5//!  identity of the old root and of the subvolume it created, which is what every later phase checks before it reuses,
6//!  exchanges, destroys or deletes anything. The journal is written through exclusively created files and never replaces
7//!  a file it did not make. One command at a time owns a conversion through a numbered claim file
8//!  (`single_owner_claim.rs`) taken before the journal is read, so no kernel lock is held.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! const claim = await takeClaim({ directory: admin, prefix: CLAIM_PREFIX }); // busy when another command owns it
13//! const journal = await Journal.load(admin);
14//! await journal.save(admin); // exclusive temporary, then rename
15//! ```
16
17use crate::fs_beneath::ObjectId;
18use crate::subvolume_error::MigrateError;
19use std::io::Write;
20use std::os::unix::fs::OpenOptionsExt;
21use std::os::unix::io::AsRawFd;
22use std::path::{Path, PathBuf};
23
24/// The name of the journal inside the worktree's administrative directory.
25pub const JOURNAL_NAME: &str = "cli-git-convert";
26
27/// The name of the kernel lock file that releases up to 0.5.20 left in the administrative directory; this release only probes for
28///  it and removes it when nothing holds it.
29pub const LOCK_NAME: &str = "cli-git-convert.lock";
30
31/// The prefix of the numbered claim files that give one command at a time the conversion of a worktree.
32pub const CLAIM_PREFIX: &str = "cli-git-convert.owner";
33
34/// The most bytes of a journal or a small registration file that are read.
35const SMALL_FILE_LIMIT: u64 = 1 << 20;
36
37/// The first line of every journal.
38const JOURNAL_HEADER: &str = "cli-git-convert v1";
39
40/// What:
41///  The phase a conversion has reached, in order.
42/// Why:
43///   A rerun reads it to skip the finished phases and to decide whether an abandon is still possible.
44///
45/// In TS you'd write (pseudocode):
46/// ```ts
47/// type Phase = 'started' | 'creating' | 'synced' | 'exchanged' | 'refreshed' | 'removed';
48/// ```
49#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
50pub enum Phase {
51    /// 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
52    ///  here, which a rerun accepts).
53    Started,
54    /// The converting subvolume is being created, or exists.
55    Creating,
56    /// The copy was made and proved equal to the original; provisional until the swap, because a writer may act after it.
57    Synced,
58    /// The path names the subvolume; the old tree sits at the converting name.
59    Exchanged,
60    /// The index stat fields describe the files of the subvolume.
61    Refreshed,
62    /// The old tree is gone; only the journal remains.
63    Removed,
64}
65
66impl Phase {
67    /// The name written to the journal.
68    fn name(self) -> &'static str {
69        return match self {
70            Phase::Started => "started",
71            Phase::Creating => "creating",
72            Phase::Synced => "synced",
73            Phase::Exchanged => "exchanged",
74            Phase::Refreshed => "refreshed",
75            Phase::Removed => "removed",
76        };
77    }
78
79    /// The phase a journal line names.
80    fn parse(text: &str) -> Option<Phase> {
81        return [Phase::Started, Phase::Creating, Phase::Synced, Phase::Exchanged, Phase::Refreshed, Phase::Removed]
82            .into_iter()
83            .find(|phase: &Phase| { return phase.name() == text; });
84    }
85}
86
87/// What:
88///  The journal of one conversion, kept in the worktree's administrative directory.
89/// Why:
90///   See the module description.
91///
92/// In TS you'd write (pseudocode):
93/// ```ts
94/// type ConvertJournal = { phase: Phase; token: string; path: string; oldIdentity: ObjectId; gitFile: Uint8Array; newIdentity?: ObjectId };
95/// ```
96#[derive(Clone, Debug, Eq, PartialEq)]
97pub struct Journal {
98    /// The phase reached.
99    pub phase: Phase,
100    /// The token the index lock holds.
101    pub token: String,
102    /// The worktree path as raw bytes.
103    pub path: Vec<u8>,
104    /// The identity of the old root: device, inode and, where the filesystem gives one, the birth time, which tells the recorded
105    ///  directory from another that later got the same inode number.
106    pub old_identity: ObjectId,
107    /// The exact bytes of the `.git` file.
108    pub git_file: Vec<u8>,
109    /// The identity of the subvolume this command created, recorded right after the creation; absent until then, so a
110    ///  directory at the converting name that the journal has no identity for is foreign.
111    pub new_identity: Option<ObjectId>,
112}
113
114/// The lowercase hexadecimal form of some bytes.
115fn hex(bytes: &[u8]) -> String {
116    return bytes.iter().map(|byte: &u8| { return format!("{byte:02x}"); }).collect();
117}
118
119/// The bytes a hexadecimal string names.
120fn unhex(text: &str) -> Option<Vec<u8>> {
121    if text.len() % 2 != 0 {
122        return None;
123    }
124    return (0..text.len()).step_by(2).map(|at: usize| { return u8::from_str_radix(text.get(at..at + 2)?, 16).ok(); }).collect();
125}
126
127/// A name for a temporary file that no other file has: this process and the clock make it, and the creation is exclusive.
128fn temporary_name() -> String {
129    let nanos: u128 = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |elapsed: std::time::Duration| { return elapsed.as_nanos(); });
130    return format!("{JOURNAL_NAME}.tmp.{}.{nanos}", std::process::id());
131}
132
133impl Journal {
134    /// The journal text.
135    fn encode(&self) -> String {
136        return format!(
137            "{JOURNAL_HEADER}\nphase={}\ntoken={}\npath={}\nold_identity={}\ngit_file={}\nnew_identity={}\n",
138            self.phase.name(),
139            self.token,
140            hex(self.path.as_slice()),
141            self.old_identity.encode(),
142            hex(self.git_file.as_slice()),
143            self.new_identity.map_or_else(|| { return String::from("-"); }, |identity: ObjectId| { return identity.encode(); })
144        );
145    }
146
147    /// The journal a text describes.
148    fn decode(text: &str) -> Result<Journal, MigrateError> {
149        let bad = || { return MigrateError::Journal(String::from("the conversion journal has a shape this command does not know; nothing was changed")); };
150        let mut lines = text.lines();
151        if lines.next() != Some(JOURNAL_HEADER) {
152            return Err(bad());
153        }
154        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); }); };
155        let new_identity: Option<ObjectId> = match field("new_identity").as_deref() {
156            None | Some("-") => None,
157            Some(encoded) => Some(ObjectId::decode(encoded).ok_or_else(bad)?),
158        };
159        // A journal written before the birth time was recorded names the old root by device and inode alone.
160        let old_identity: ObjectId = match field("old_identity") {
161            Some(encoded) => ObjectId::decode(encoded.as_str()).ok_or_else(bad)?,
162            None => ObjectId {
163                device: field("old_device").and_then(|digits: String| { return digits.parse().ok(); }).ok_or_else(bad)?,
164                inode: field("old_inode").and_then(|digits: String| { return digits.parse().ok(); }).ok_or_else(bad)?,
165                birth: None,
166            },
167        };
168        return Ok(Journal {
169            phase: Phase::parse(field("phase").ok_or_else(bad)?.as_str()).ok_or_else(bad)?,
170            token: field("token").ok_or_else(bad)?,
171            path: unhex(field("path").ok_or_else(bad)?.as_str()).ok_or_else(bad)?,
172            old_identity,
173            git_file: unhex(field("git_file").ok_or_else(bad)?.as_str()).ok_or_else(bad)?,
174            new_identity,
175        });
176    }
177
178    /// Write the journal into a file made for it alone: created exclusively, so nothing that is there is truncated or followed.
179    fn write_temporary(&self, admin: &Path) -> std::io::Result<PathBuf> {
180        let temporary: PathBuf = admin.join(temporary_name());
181        let mut file: std::fs::File = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&temporary)?;
182        let written: std::io::Result<()> = file.write_all(self.encode().as_bytes()).and_then(|()| { return file.sync_all(); });
183        if let Err(error) = written {
184            let _removed: std::io::Result<()> = std::fs::remove_file(&temporary);
185            return Err(error);
186        }
187        return Ok(temporary);
188    }
189
190    /// Replace the journal with this one: written beside it, synchronized, renamed into place.
191    pub fn save(&self, admin: &Path) -> Result<(), MigrateError> {
192        let target: PathBuf = admin.join(JOURNAL_NAME);
193        let written: std::io::Result<()> = (|| {
194            let temporary: PathBuf = self.write_temporary(admin)?;
195            if let Err(error) = std::fs::rename(&temporary, &target) {
196                let _removed: std::io::Result<()> = std::fs::remove_file(&temporary);
197                return Err(error);
198            }
199            return std::fs::File::open(admin).and_then(|directory: std::fs::File| { return directory.sync_all(); });
200        })();
201        return written.map_err(|error: std::io::Error| { return MigrateError::io("write the conversion journal", target.as_path(), &error); });
202    }
203
204    /// Make this the journal of a conversion that has none: it fails when any journal exists, so a journal another command
205    ///  made is never replaced.
206    pub fn create_new(&self, admin: &Path) -> Result<(), MigrateError> {
207        let target: PathBuf = admin.join(JOURNAL_NAME);
208        let written: std::io::Result<()> = (|| {
209            let temporary: PathBuf = self.write_temporary(admin)?;
210            let linked: std::io::Result<()> = std::fs::hard_link(&temporary, &target);
211            let _removed: std::io::Result<()> = std::fs::remove_file(&temporary);
212            linked?;
213            return std::fs::File::open(admin).and_then(|directory: std::fs::File| { return directory.sync_all(); });
214        })();
215        return written.map_err(|error: std::io::Error| { return MigrateError::io("create the conversion journal", target.as_path(), &error); });
216    }
217
218    /// The journal of an administrative directory, if one exists.
219    pub fn load(admin: &Path) -> Result<Option<Journal>, MigrateError> {
220        let path: PathBuf = admin.join(JOURNAL_NAME);
221        match read_plain_file(path.as_path(), SMALL_FILE_LIMIT) {
222            Ok(bytes) => return Journal::decode(String::from_utf8_lossy(bytes.as_slice()).as_ref()).map(Some),
223            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
224            Err(error) => return Err(MigrateError::io("read the conversion journal", path.as_path(), &error)),
225        }
226    }
227}
228
229/// What:
230///  The bytes of the plain file at `path`, opened without blocking and without following a link, refused when it is anything
231///  else or larger than `limit`.
232/// Why:
233///   Opening a named pipe for reading waits for a writer, and a command that waits while it owns the conversion blocks every
234///  other command that wants it; the type is read from the open handle, and the read is bounded.
235///
236/// In TS you'd write (pseudocode):
237/// ```ts
238/// function readPlainFile(path: string, limit: number): Buffer; // throws
239/// ```
240pub fn read_plain_file(path: &Path, limit: u64) -> std::io::Result<Vec<u8>> {
241    let file: std::fs::File = std::fs::OpenOptions::new().read(true).custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW | libc::O_CLOEXEC).open(path)?;
242    if !file.metadata()?.file_type().is_file() {
243        return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "it is not a plain file (a named pipe, a device or a directory)"));
244    }
245    let mut bytes: Vec<u8> = Vec::new();
246    std::io::Read::read_to_end(&mut std::io::Read::take(&file, limit + 1), &mut bytes)?;
247    if bytes.len() as u64 > limit {
248        return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "it is larger than this command reads"));
249    }
250    return Ok(bytes);
251}
252
253/// The text of the plain file at `path`, read like `read_plain_file`.
254pub fn read_plain_text(path: &Path) -> std::io::Result<String> {
255    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"); });
256}
257
258/// Whether `found` is the object the journal recorded as `recorded`: the same device and inode and, when a birth time was
259///  recorded, the same birth time.
260pub fn is_recorded_object(recorded: &ObjectId, found: &ObjectId) -> bool {
261    return recorded.device == found.device && recorded.inode == found.inode && (recorded.birth.is_none() || recorded.birth == found.birth);
262}
263
264/// What:
265///  Whether a command of an older release still holds the conversion's kernel lock on `admin`, retiring the lock file when
266///  none does.
267/// Why:
268///   Releases up to 0.5.20 serialized conversions with an advisory kernel lock on `cli-git-convert.lock` and left the file
269///  in place. This release serializes with a claim file instead and takes no kernel lock, but an older command may still be
270///  converting the worktree, and two commands must never convert one worktree at once. The probe asks without waiting and
271///  gives the lock straight back; a file nobody holds is removed, so the leftover disappears after the upgrade. A file that
272///  is absent costs nothing, and a file that is not a plain file is left where it is.
273///
274/// In TS you'd write (pseudocode):
275/// ```ts
276/// const heldByOlderRelease = await retireLegacyLock(admin); // false when absent, free (and now removed) or not a plain file
277/// ```
278pub fn legacy_lock_is_held(admin: &Path) -> Result<bool, MigrateError> {
279    let path: PathBuf = admin.join(LOCK_NAME);
280    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) {
281        Ok(opened) => opened,
282        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
283        Err(error) => return Err(MigrateError::io("open the conversion lock an older release left", path.as_path(), &error)),
284    };
285    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();
286    if !plain {
287        return Ok(false);
288    }
289    // SAFETY: `flock` takes an open descriptor, which `file` keeps open for the call.
290    let result: libc::c_int = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
291    if result != 0 {
292        let error: std::io::Error = std::io::Error::last_os_error();
293        if error.kind() == std::io::ErrorKind::WouldBlock {
294            return Ok(true);
295        }
296        return Err(MigrateError::io("probe the conversion lock an older release left", path.as_path(), &error));
297    }
298    // Removed while the probe still holds it, so no older command can take the name between the probe and the removal.
299    return match std::fs::remove_file(&path) {
300        Ok(()) => Ok(false),
301        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
302        Err(error) => Err(MigrateError::io("remove the conversion lock an older release left", path.as_path(), &error)),
303    };
304}