Skip to main content

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;