git_policy_cli/recovery_files.rs
1//! What:
2//! No-follow file operations recovery and landing share:
3//! existence probes,
4//! exact reads,
5//! byte comparison,
6//! owner-preserving hard links,
7//! proof that a real `index.lock` is the
8//! exact file a transaction created,
9//! and installing an index through such a lock.
10//! Why:
11//! Recovery removes or installs through a real `index.lock` only when the journal proves the
12//! lock is the transaction's own,
13//! by device,
14//! inode and filesystem identity,
15//! never by path;
16//! the incumbent's `commit-transaction-recovery-files.ts`,
17//! `-validation.ts` and
18//! `commit-transaction-install-link.ts` define these steps.
19//!
20//! In TS you'd write (pseudocode):
21//! ```ts
22//! await installRecoveredIndex({ lockPath, realIndexPath, postIndexPath, journal });
23//! ```
24
25/// Debug diagnostics.
26use super::diagnostic_log::debug;
27/// The filesystem identity recorded with a lock.
28use super::filesystem_identity::filesystem_identity;
29/// Private reads and directory syncs.
30use super::private_storage::{ReadRefusal, read_regular_file, sync_directory};
31/// The fail-closed recovery failure.
32use super::recovery_error::{RecoveryError, io_failure};
33/// The recorded identity of a real `index.lock`.
34use super::transaction_journal::LockIdentity;
35/// The trait that gives files `.write_all`.
36use std::io::Write;
37/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
38use std::path::{Path, PathBuf};
39
40/// Largest recovery artifact read whole:
41/// indexes of very large repositories stay well below it.
42pub const RECOVERY_FILE_LIMIT: u64 = 2 * 1024 * 1024 * 1024;
43
44/// What:
45/// Whether anything exists at `path`,
46/// following links,
47/// as Node's `access` does.
48/// Any error other than "missing" is reported.
49/// Why:
50/// The incumbent's `recoveryPathExists`.
51///
52/// In TS you'd write (pseudocode):
53/// ```ts
54/// async function recoveryPathExists(path: string): Promise<boolean>;
55/// ```
56pub fn recovery_path_exists(path: &Path) -> Result<bool, RecoveryError> {
57 match std::fs::metadata(path) {
58 Ok(_) => return Ok(true),
59 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
60 Err(error) => return Err(io_failure("probing", path, &error)),
61 }
62}
63
64/// What:
65/// Whether anything exists at `path` without following a final link.
66/// Why:
67/// The incumbent's `pathPresent` and `pathExists` (both `lstat`).
68///
69/// In TS you'd write (pseudocode):
70/// ```ts
71/// async function pathPresent(path: string): Promise<boolean>;
72/// ```
73pub fn path_present(path: &Path) -> Result<bool, RecoveryError> {
74 match std::fs::symlink_metadata(path) {
75 Ok(_) => return Ok(true),
76 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
77 Err(error) => return Err(io_failure("probing", path, &error)),
78 }
79}
80
81/// What:
82/// The exact bytes of a regular recovery file,
83/// read without following a link.
84/// Why:
85/// `readRegularRecoveryFile`:
86/// a link or a non-regular file is unsafe state.
87///
88/// In TS you'd write (pseudocode):
89/// ```ts
90/// async function readRegularRecoveryFile(path: string): Promise<Uint8Array>;
91/// ```
92pub fn read_recovery_file(path: &Path) -> Result<Vec<u8>, RecoveryError> {
93 match read_regular_file(path, RECOVERY_FILE_LIMIT) {
94 Ok(bytes) => return Ok(bytes),
95 Err(ReadRefusal::Missing) => {
96 return Err(RecoveryError(format!(
97 "Transaction recovery file is missing: {}",
98 path.display()
99 )));
100 }
101 Err(ReadRefusal::NotRegular | ReadRefusal::TooLarge) => {
102 return Err(RecoveryError(format!(
103 "Unsafe transaction recovery file: {}",
104 path.display()
105 )));
106 }
107 Err(ReadRefusal::Io(error)) => return Err(io_failure("reading", path, &error)),
108 }
109}
110
111/// What:
112/// The device and inode of a path as decimal text,
113/// without following a link,
114/// with
115/// whether it is a regular file.
116/// Why:
117/// Identities are recorded and compared as the incumbent's `String(metadata.dev)`.
118///
119/// In TS you'd write (pseudocode):
120/// ```ts
121/// const { dev, ino } = await lstat(path, { bigint: true }); [String(dev), String(ino)]
122/// ```
123pub fn file_identity_of(path: &Path) -> std::io::Result<(String, String, bool)> {
124 let metadata: std::fs::Metadata = std::fs::symlink_metadata(path)?;
125 return Ok(metadata_identity(&metadata));
126}
127
128/// What:
129/// The device,
130/// inode and regular-file flag of metadata.
131/// Why:
132/// Shared by path and descriptor identities.
133///
134/// In TS you'd write (pseudocode):
135/// ```ts
136/// [String(metadata.dev), String(metadata.ino), metadata.isFile()]
137/// ```
138pub fn metadata_identity(metadata: &std::fs::Metadata) -> (String, String, bool) {
139 #[cfg(unix)]
140 {
141 use std::os::unix::fs::MetadataExt;
142 return (
143 metadata.dev().to_string(),
144 metadata.ino().to_string(),
145 metadata.file_type().is_file(),
146 );
147 }
148 #[cfg(not(unix))]
149 {
150 // Windows has no stable device and inode in the standard library; the identity is
151 // recorded as unknown, so a recovery there fails closed instead of guessing.
152 return (String::new(), String::new(), metadata.file_type().is_file());
153 }
154}
155
156/// What:
157/// Whether two files hold the same bytes,
158/// streamed in fixed chunks through no-follow
159/// handles;
160/// a file that cannot be opened is a failure.
161/// Why:
162/// `snapshotFilesEqual`:
163/// recovery compares the real index with recorded snapshots,
164/// which
165/// can be large,
166/// so neither is read whole.
167///
168/// In TS you'd write (pseudocode):
169/// ```ts
170/// async function snapshotFilesEqual({ leftPath, rightPath }): Promise<boolean>;
171/// ```
172pub fn files_equal(left: &Path, right: &Path) -> Result<bool, RecoveryError> {
173 let mut first: std::fs::File = open_no_follow(left)?;
174 let mut second: std::fs::File = open_no_follow(right)?;
175 // `mut` allows reusing both buffers for every chunk.
176 let mut first_buffer: Vec<u8> = vec![0; COMPARISON_CHUNK];
177 let mut second_buffer: Vec<u8> = vec![0; COMPARISON_CHUNK];
178 loop {
179 let first_count: usize = match fill(&mut first, first_buffer.as_mut_slice()) {
180 Ok(count) => count,
181 Err(error) => return Err(io_failure("reading", left, &error)),
182 };
183 let second_count: usize = match fill(&mut second, second_buffer.as_mut_slice()) {
184 Ok(count) => count,
185 Err(error) => return Err(io_failure("reading", right, &error)),
186 };
187 if first_count != second_count
188 || first_buffer[..first_count] != second_buffer[..second_count]
189 {
190 return Ok(false);
191 }
192 if first_count < COMPARISON_CHUNK {
193 return Ok(true);
194 }
195 }
196}
197
198/// Bytes compared per chunk.
199const COMPARISON_CHUNK: usize = 64 * 1024;
200
201/// What:
202/// Read until `buffer` is full or the file ends;
203/// returns how many bytes were read.
204/// Why:
205/// A short read must not end the comparison early.
206///
207/// In TS you'd write (pseudocode):
208/// ```ts
209/// let total = 0; while (total < buffer.length) { const { bytesRead } = await handle.read(buffer, total); if (!bytesRead) break; total += bytesRead; }
210/// ```
211fn fill(file: &mut std::fs::File, buffer: &mut [u8]) -> std::io::Result<usize> {
212 use std::io::Read;
213 let mut total: usize = 0;
214 while total < buffer.len() {
215 let count: usize = file.read(&mut buffer[total..])?;
216 if count == 0 {
217 break;
218 }
219 total += count;
220 }
221 return Ok(total);
222}
223
224/// What:
225/// Open an existing file for reading without following a final link.
226/// Why:
227/// Snapshots and the real index are compared as the files themselves.
228///
229/// In TS you'd write (pseudocode):
230/// ```ts
231/// await open(path, O_RDONLY | O_NOFOLLOW)
232/// ```
233fn open_no_follow(path: &Path) -> Result<std::fs::File, RecoveryError> {
234 let mut options: std::fs::OpenOptions = std::fs::OpenOptions::new();
235 options.read(true);
236 #[cfg(unix)]
237 {
238 use std::os::unix::fs::OpenOptionsExt;
239 options.custom_flags(libc::O_NOFOLLOW);
240 }
241 match options.open(path) {
242 Ok(file) => return Ok(file),
243 Err(error) => return Err(io_failure("opening", path, &error)),
244 }
245}
246
247/// What:
248/// A file's bytes,
249/// following links as Node's `readFile` does,
250/// or nothing when missing.
251/// Why:
252/// Conclusion entries and index snapshots may be absent.
253///
254/// In TS you'd write (pseudocode):
255/// ```ts
256/// async function readOptional(path: string): Promise<Uint8Array | typeof ENTRY_ABSENT>;
257/// ```
258pub fn read_optional(path: &Path) -> Result<Option<Vec<u8>>, RecoveryError> {
259 match std::fs::read(path) {
260 Ok(bytes) => return Ok(Some(bytes)),
261 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
262 Err(error) => return Err(io_failure("reading", path, &error)),
263 }
264}
265
266/// What:
267/// Hard-link `source` at `linked` and prove the new name is the expected regular file.
268/// Why:
269/// `createOwnedFileLink`:
270/// an install or a stabilized artifact keeps the exact inode,
271/// so a
272/// file swapped in under the old name is detected instead of installed.
273///
274/// In TS you'd write (pseudocode):
275/// ```ts
276/// async function createOwnedFileLink({ sourcePath, linkedPath, expectedDevice, expectedInode }): Promise<void>;
277/// ```
278pub fn create_owned_file_link(
279 source: &Path,
280 linked: &Path,
281 expected_device: &str,
282 expected_inode: &str,
283) -> Result<(), RecoveryError> {
284 remove_file_if_present(linked)?;
285 if let Err(error) = std::fs::hard_link(source, linked) {
286 return Err(io_failure("linking", linked, &error));
287 }
288 let matches: bool = match file_identity_of(linked) {
289 Ok((device, inode, regular)) => {
290 regular && device == expected_device && inode == expected_inode
291 }
292 Err(_) => false,
293 };
294 if !matches {
295 remove_file_if_present(linked)?;
296 return Err(RecoveryError(format!(
297 "Commit transaction file link identity changed: {}",
298 linked.display()
299 )));
300 }
301 return Ok(());
302}
303
304/// What:
305/// Remove one file,
306/// treating a missing one as removed.
307/// Why:
308/// `rm(path, { force: true })` for single files.
309///
310/// In TS you'd write (pseudocode):
311/// ```ts
312/// await rm(path, { force: true });
313/// ```
314pub fn remove_file_if_present(path: &Path) -> Result<(), RecoveryError> {
315 match std::fs::remove_file(path) {
316 Ok(()) => return Ok(()),
317 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
318 Err(error) => return Err(io_failure("removing", path, &error)),
319 }
320}
321
322/// What:
323/// Why `lock_path` is not the exact `index.lock` the journal recorded,
324/// or nothing when it
325/// is:
326/// a regular file,
327/// not a link,
328/// with the recorded device,
329/// inode and filesystem identity.
330/// A failure to inspect it is an error,
331/// never a mismatch.
332/// Why:
333/// The incumbent's `assertOwnedLock` throws its recovery error only for a mismatch;
334/// an
335/// `lstat` or filesystem-identity failure propagates,
336/// and `releaseOwnedLock` treats only the
337/// mismatch as another owner's lock.
338///
339/// In TS you'd write (pseudocode):
340/// ```ts
341/// try { await assertOwnedLock({ journal, lockPath }); return undefined; } catch (e) { if (e instanceof CommitTransactionRecoveryError) return e.message; throw e; }
342/// ```
343pub fn owned_lock_mismatch(
344 journal: &LockIdentity,
345 lock_path: &Path,
346) -> Result<Option<String>, RecoveryError> {
347 let (device, inode, regular) = match file_identity_of(lock_path) {
348 Ok(found) => found,
349 Err(error) => return Err(io_failure("inspecting the index lock", lock_path, &error)),
350 };
351 if !regular {
352 return Ok(Some(format!(
353 "Index lock is not a regular owned file: {}",
354 lock_path.display()
355 )));
356 }
357 let Some(filesystem) = filesystem_identity(lock_path) else {
358 return Err(RecoveryError(format!(
359 "unable to resolve the filesystem identity for {}",
360 lock_path.display()
361 )));
362 };
363 if filesystem != journal.fs_id || device != journal.file.device || inode != journal.file.inode {
364 return Ok(Some(format!(
365 "Index lock identity changed: {}",
366 lock_path.display()
367 )));
368 }
369 return Ok(None);
370}
371
372/// What:
373/// Require `lock_path` to be the exact `index.lock` the journal recorded.
374/// Why:
375/// `assertOwnedLock`:
376/// cli-git never deletes or installs through a foreign lock.
377///
378/// In TS you'd write (pseudocode):
379/// ```ts
380/// async function assertOwnedLock({ journal, lockPath }): Promise<void>; // throws when foreign
381/// ```
382pub fn assert_owned_lock(journal: &LockIdentity, lock_path: &Path) -> Result<(), RecoveryError> {
383 if let Some(mismatch) = owned_lock_mismatch(journal, lock_path)? {
384 return Err(RecoveryError(mismatch));
385 }
386 return Ok(());
387}
388
389/// What:
390/// What happened when a lock the transaction no longer needs was released.
391/// Why:
392/// Callers log it;
393/// only an owned lock is removed.
394///
395/// In TS you'd write (pseudocode):
396/// ```ts
397/// type OwnedLockRelease = 'released' | 'absent' | 'foreign';
398/// ```
399#[derive(Clone, Copy, Debug, Eq, PartialEq)]
400pub enum LockRelease {
401 /// The owned lock was removed.
402 Released,
403 /// No lock was there.
404 Absent,
405 /// Another owner's lock is there now and was left in place.
406 Foreign,
407}
408
409/// What:
410/// The incumbent's name of a release outcome.
411/// Why:
412/// Debug lines read `attempt 1 lock released`,
413/// as the incumbent's do.
414///
415/// In TS you'd write (pseudocode):
416/// ```ts
417/// release // 'released' | 'absent' | 'foreign'
418/// ```
419pub fn lock_release_name(release: LockRelease) -> &'static str {
420 match release {
421 LockRelease::Released => return "released",
422 LockRelease::Absent => return "absent",
423 LockRelease::Foreign => return "foreign",
424 }
425}
426
427/// What:
428/// Remove the real-index lock only when it is still the exact file the transaction made.
429/// Why:
430/// `releaseOwnedLock`:
431/// a lock another process holds now is left alone.
432///
433/// In TS you'd write (pseudocode):
434/// ```ts
435/// async function releaseOwnedLock({ journal, lockPath }): Promise<OwnedLockRelease>;
436/// ```
437pub fn release_owned_lock(
438 journal: &LockIdentity,
439 lock_path: &Path,
440) -> Result<LockRelease, RecoveryError> {
441 if !recovery_path_exists(lock_path)? {
442 return Ok(LockRelease::Absent);
443 }
444 if let Some(mismatch) = owned_lock_mismatch(journal, lock_path)? {
445 debug(
446 "releaseOwnedLock",
447 format!("leaving lock another owner holds: {mismatch}").as_str(),
448 );
449 return Ok(LockRelease::Foreign);
450 }
451 if let Err(error) = std::fs::remove_file(lock_path) {
452 return Err(io_failure(
453 "removing the owned index lock",
454 lock_path,
455 &error,
456 ));
457 }
458 debug(
459 "releaseOwnedLock",
460 format!("released owned lock {}", lock_path.display()).as_str(),
461 );
462 return Ok(LockRelease::Released);
463}
464
465/// What:
466/// Give an open file the access and modification times of `source`.
467/// Why:
468/// `applyIndexTimestamps`:
469/// an installed index keeps the times of the index it was
470/// computed from,
471/// so Git's racy-entry protection still re-hashes same-second edits.
472///
473/// In TS you'd write (pseudocode):
474/// ```ts
475/// await applyIndexTimestamps({ sourcePath, handle });
476/// ```
477pub fn apply_timestamps(source: &Path, file: &std::fs::File) -> std::io::Result<()> {
478 let metadata: std::fs::Metadata = std::fs::metadata(source)?;
479 let times: std::fs::FileTimes = std::fs::FileTimes::new()
480 .set_accessed(metadata.accessed()?)
481 .set_modified(metadata.modified()?);
482 return file.set_times(times);
483}
484
485/// What:
486/// Install a recorded post-index through the exact owned lock:
487/// write it into the lock,
488/// link the lock under a private name,
489/// rename that name over the real index,
490/// then remove
491/// the lock.
492/// Why:
493/// `installRecoveredIndex`:
494/// the install never goes through the mutable lock path,
495/// and the
496/// lock is proven to be the owned one before and after each step.
497///
498/// In TS you'd write (pseudocode):
499/// ```ts
500/// async function installRecoveredIndex({ lockPath, realIndexPath, postIndexPath, journal }): Promise<void>;
501/// ```
502pub fn install_recovered_index(
503 lock_path: &Path,
504 real_index: &Path,
505 post_index: &Path,
506 journal: &LockIdentity,
507) -> Result<(), RecoveryError> {
508 let bytes: Vec<u8> = read_recovery_file(post_index)?;
509 let mut lock: std::fs::File = open_lock_no_follow(lock_path)?;
510 let opened: std::fs::Metadata = match lock.metadata() {
511 Ok(metadata) => metadata,
512 Err(error) => return Err(io_failure("inspecting the index lock", lock_path, &error)),
513 };
514 let (device, inode, _) = metadata_identity(&opened);
515 if device != journal.file.device || inode != journal.file.inode {
516 return Err(RecoveryError(format!(
517 "Index lock identity changed: {}",
518 lock_path.display()
519 )));
520 }
521 let written: std::io::Result<()> = write_lock_contents(&mut lock, bytes.as_slice(), post_index);
522 if let Err(error) = written {
523 return Err(io_failure(
524 "writing the recovered index into",
525 lock_path,
526 &error,
527 ));
528 }
529 assert_owned_lock(journal, lock_path)?;
530 let install: PathBuf = parent_of(post_index).join("install.index");
531 create_owned_file_link(
532 lock_path,
533 install.as_path(),
534 journal.file.device.as_str(),
535 journal.file.inode.as_str(),
536 )?;
537 drop(lock);
538 if let Err(error) = std::fs::rename(install.as_path(), real_index) {
539 return Err(io_failure(
540 "installing the recovered index",
541 real_index,
542 &error,
543 ));
544 }
545 assert_owned_lock(journal, lock_path)?;
546 if let Err(error) = std::fs::remove_file(lock_path) {
547 return Err(io_failure(
548 "removing the owned index lock",
549 lock_path,
550 &error,
551 ));
552 }
553 let parent: &Path = parent_of(real_index);
554 if let Err(error) = sync_directory(parent) {
555 return Err(io_failure("syncing", parent, &error));
556 }
557 return Ok(());
558}
559
560/// What:
561/// The parent directory of a path,
562/// or the path itself when it has none.
563/// Why:
564/// Recorded paths are absolute files,
565/// so they always have a parent;
566/// a path without one
567/// names itself instead of panicking.
568///
569/// In TS you'd write (pseudocode):
570/// ```ts
571/// dirname(path)
572/// ```
573fn parent_of(path: &Path) -> &Path {
574 match path.parent() {
575 Some(parent) => return parent,
576 None => return path,
577 }
578}
579
580/// What:
581/// Open an existing lock for reading and writing without following a link.
582/// Why:
583/// The lock must be the file itself,
584/// never a planted link.
585///
586/// In TS you'd write (pseudocode):
587/// ```ts
588/// await open(lockPath, O_RDWR | O_NOFOLLOW)
589/// ```
590fn open_lock_no_follow(lock_path: &Path) -> Result<std::fs::File, RecoveryError> {
591 let mut options: std::fs::OpenOptions = std::fs::OpenOptions::new();
592 options.read(true).write(true);
593 #[cfg(unix)]
594 {
595 use std::os::unix::fs::OpenOptionsExt;
596 options.custom_flags(libc::O_NOFOLLOW);
597 }
598 match options.open(lock_path) {
599 Ok(file) => return Ok(file),
600 Err(error) => return Err(io_failure("opening the index lock", lock_path, &error)),
601 }
602}
603
604/// What:
605/// Replace a lock's contents with `bytes`,
606/// give it the source's times,
607/// and sync it.
608/// Why:
609/// The steps between the identity checks of `install_recovered_index`.
610///
611/// In TS you'd write (pseudocode):
612/// ```ts
613/// await lock.truncate(0); await lock.writeFile(bytes); await applyIndexTimestamps(...); await lock.sync();
614/// ```
615fn write_lock_contents(
616 lock: &mut std::fs::File,
617 bytes: &[u8],
618 source: &Path,
619) -> std::io::Result<()> {
620 lock.set_len(0)?;
621 lock.write_all(bytes)?;
622 apply_timestamps(source, lock)?;
623 return lock.sync_all();
624}
625
626/// What:
627/// The path of Git's owner PID file for a lock,
628/// `<base>~pid.lock` beside `<base>.lock`.
629/// Why:
630/// With `core.lockfilePid=true`,
631/// Git writes `index~pid.lock` beside `index.lock`;
632/// the
633/// incumbent's `lockPidPath` names it this way.
634///
635/// In TS you'd write (pseudocode):
636/// ```ts
637/// lockPidPath('/repo/.git/index') // '/repo/.git/index~pid.lock'
638/// ```
639pub fn lock_pid_path(real_index: &Path) -> PathBuf {
640 // `as_os_str().to_owned()` copies the raw path so the suffix can be appended.
641 let mut name: std::ffi::OsString = real_index.as_os_str().to_owned();
642 name.push("~pid.lock");
643 return PathBuf::from(name);
644}
645
646/// Recovery file controls stay out of the release executable.
647#[cfg(test)]
648#[path = "recovery_files_tests.rs"]
649pub(crate) mod tests;