Skip to main content

git_policy_cli/
real_index_lock.rs

1//! What:
2//!  The real `index.lock` a landing holds,
3//!  created exclusively,
4//!  identified by device,
5//!  inode and
6//!       filesystem,
7//!  announced through `index~pid.lock`,
8//!  and released only while it is still the file
9//!       that was created.
10//! Why:
11//!  The landing's index install depends on the exact lock file it created;
12//!  a replaced lock must
13//!      never be deleted,
14//!  and a lock this process did not create must never be released
15//!      (`src/policy-engine/commit-landing-index-lock.ts`).
16//!
17//! In TS you'd write (pseudocode):
18//! ```ts
19//! await using lock = await openRealIndexLock({ realIndexPath, timeoutMs });
20//! ```
21
22/// The lock identity record the transaction journal stores, and the numbered record's name.
23use super::transaction_journal::{IndexLockRecord, LockIdentity, index_lock_record_filename};
24/// The record's exact bytes.
25use super::transaction_journal_encode::encode_index_lock;
26/// Debug and warning text to the diagnostic log.
27use super::diagnostic_log::{debug, warn};
28/// The recovery install of a recorded post-index through the exact owned lock, and the release of that lock.
29use super::recovery_files::{LockRelease, install_recovered_index, lock_release_name, release_owned_lock};
30/// Recovery failures, which the install reports.
31use super::recovery_error::RecoveryError;
32#[cfg(unix)]
33use super::transaction_journal::FileIdentity;
34/// The waiting coordination and its failure.
35use super::index_writer_coordination::{CoordinationFailure, wait_for_lock};
36/// The filesystem identity of the worktree's volume.
37#[cfg(unix)]
38use super::filesystem_identity::filesystem_identity;
39/// Announcing this process through the PID file, the journal record, and the lock's mode.
40use super::private_storage::{protect_path, sync_directory, write_private_file};
41/// `Path` and `PathBuf` are borrowed and owned filesystem paths.
42use std::path::{Path, PathBuf};
43
44/// Module tag for diagnostics.
45const TAG: &str = "acquireRealIndexLock";
46
47/// What:
48///  A real `index.lock` created by this process.
49/// Why:
50///   Holding the identity lets the release prove the file is still the one created.
51///
52/// In TS you'd write (pseudocode):
53/// ```ts
54/// type RealIndexLock = { lockPath: string; identity: LockIdentity; installIndex(source): Promise<void> };
55/// ```
56#[derive(Debug, Eq, PartialEq)]
57pub struct RealIndexLock {
58    /// The lock path,
59    ///  `<real index>.lock`.
60    lock_path: PathBuf,
61    /// The real index the lock guards,
62    ///  which an install replaces.
63    real_index: PathBuf,
64    /// The PID file path,
65    ///  `<real index>~pid.lock`.
66    pid_path: PathBuf,
67    /// The identity recorded when the lock was created.
68    identity: LockIdentity,
69}
70
71/// What:
72///  The lock path of a real index.
73/// Why:
74///   Git names the lock beside the index.
75///
76/// In TS you'd write (pseudocode):
77/// ```ts
78/// `${realIndexPath}.lock`
79/// ```
80pub fn real_lock_path(real_index: &Path) -> PathBuf {
81    let mut name: std::ffi::OsString = real_index.as_os_str().to_owned();
82    name.push(".lock");
83    return PathBuf::from(name);
84}
85
86/// What:
87///  The PID file path of a real index.
88/// Why:
89///   Git's `core.lockfilePid` file lives beside the lock.
90///
91/// In TS you'd write (pseudocode):
92/// ```ts
93/// lockPidPath(realIndexPath) // `${realIndexPath}~pid.lock`
94/// ```
95pub fn real_pid_path(real_index: &Path) -> PathBuf {
96    let mut name: std::ffi::OsString = real_index.as_os_str().to_owned();
97    name.push("~pid.lock");
98    return PathBuf::from(name);
99}
100
101impl RealIndexLock {
102    /// What:
103    ///  The identity recorded for the lock.
104    /// Why:
105    ///   The landing journal stores it.
106    ///
107    /// In TS you'd write (pseudocode):
108    /// ```ts
109    /// lock.identity
110    /// ```
111    pub fn identity(&self) -> &LockIdentity {
112        return &self.identity;
113    }
114
115    /// What:
116    ///  Whether the lock path still names the file this process created.
117    /// Why:
118    ///   A release or install must not act on a replaced lock.
119    ///
120    /// In TS you'd write (pseudocode):
121    /// ```ts
122    /// await assertLockIdentity({ lockPath, identity });
123    /// ```
124    pub fn assert_identity(&self) -> Result<(), String> {
125        return same_file_identity(self.lock_path.as_path(), &self.identity);
126    }
127
128    /// What:
129    ///  Release the lock and this process's PID file,
130    ///  refusing a lock that was replaced.
131    /// Why:
132    ///   A replaced lock belongs to another process and stays in place.
133    ///
134    /// In TS you'd write (pseudocode):
135    /// ```ts
136    /// await lock[Symbol.asyncDispose]();
137    /// ```
138    pub fn release(self) -> Result<(), String> {
139        self.assert_identity()?;
140        if let Err(error) = std::fs::remove_file(&self.lock_path) {
141            return Err(error.to_string());
142        }
143        return remove_own_pid_file(self.pid_path.as_path());
144    }
145
146    /// What:
147    ///  Install a computed index through the held lock,
148    ///  consuming the lock,
149    ///  then remove this process's PID file.
150    /// Why:
151    ///   `installIndex`:
152    ///  the bytes are written into the lock this process created,
153    ///  the lock is linked under a private name beside the post-index,
154    ///  and that name is renamed over the real index,
155    ///  so the new index keeps the lock's owner.
156    ///  The lock is not released here on failure:
157    ///  once the landing record is durable,
158    ///  recovery completes the install through the same lock.
159    ///
160    /// In TS you'd write (pseudocode):
161    /// ```ts
162    /// await lock.installIndex(postIndexPath);
163    /// ```
164    pub fn install(self, post_index: &Path) -> Result<(), RecoveryError> {
165        install_recovered_index(
166            self.lock_path.as_path(),
167            self.real_index.as_path(),
168            post_index,
169            &self.identity,
170        )?;
171        debug(TAG, format!("installed {} through {}", self.real_index.display(), self.lock_path.display()).as_str());
172        return remove_own_pid_file(self.pid_path.as_path()).map_err(RecoveryError);
173    }
174
175    /// What:
176    ///  Release a lock a landing leaves without installing:
177    ///  the lock is removed only while it is still the file this process created,
178    ///  and the PID file goes either way.
179    /// Why:
180    ///   The incumbent's disposal warns when the lock is gone or another owner's now
181    ///  and still removes its own PID file,
182    ///  and throws only for an operating-system error.
183    ///
184    /// In TS you'd write (pseudocode):
185    /// ```ts
186    /// await lock[Symbol.asyncDispose]();
187    /// ```
188    pub fn release_after_landing(self) -> Result<(), String> {
189        match release_owned_lock(&self.identity, self.lock_path.as_path()) {
190            Ok(LockRelease::Released) => {}
191            Ok(other) => warn(
192                TAG,
193                format!("index lock was not this transaction's at release ({}): {}", lock_release_name(other), self.lock_path.display()).as_str(),
194            ),
195            Err(error) => return Err(error.0),
196        }
197        return remove_own_pid_file(self.pid_path.as_path());
198    }
199}
200
201/// What:
202///  Whether a path is a regular file with the recorded device and inode,
203///  without following links.
204/// Why:
205///   Following a link would compare another file's identity.
206///
207/// In TS you'd write (pseudocode):
208/// ```ts
209/// const metadata = await lstat(path, { bigint: true });
210/// ```
211#[cfg(unix)]
212fn same_file_identity(path: &Path, identity: &LockIdentity) -> Result<(), String> {
213    use std::os::unix::fs::MetadataExt;
214    let metadata: std::fs::Metadata = match std::fs::symlink_metadata(path) {
215        Ok(found) => found,
216        Err(error) => return Err(error.to_string()),
217    };
218    if !metadata.is_file()
219        || metadata.dev().to_string() != identity.file.device
220        || metadata.ino().to_string() != identity.file.inode
221    {
222        return Err(format!("the index lock identity changed: {}", path.display()));
223    }
224    return Ok(());
225}
226
227/// What:
228///  On platforms without an inode,
229///  the identity cannot be checked,
230///  so the lock is never trusted.
231/// Why:
232///   The incumbent refuses a landing without a filesystem identity too.
233///
234/// In TS you'd write (pseudocode):
235/// ```ts
236/// throw new TypeError('filesystem identity unavailable');
237/// ```
238// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
239#[cfg(not(unix))]
240fn same_file_identity(path: &Path, _identity: &LockIdentity) -> Result<(), String> {
241    return Err(format!("the index lock identity cannot be checked here: {}", path.display()));
242}
243
244/// What:
245///  Create the lock file exclusively with private permissions.
246/// Why:
247///   `O_EXCL` is the mutual exclusion;
248///  an existing file means another owner holds the lock.
249///
250/// In TS you'd write (pseudocode):
251/// ```ts
252/// open(lockPath, O_CREAT | O_EXCL | O_RDWR, 0o600);
253/// ```
254#[cfg(unix)]
255fn create_exclusive(path: &Path) -> Result<bool, String> {
256    use std::os::unix::fs::OpenOptionsExt;
257    let mut options: std::fs::OpenOptions = std::fs::OpenOptions::new();
258    options.write(true).create_new(true).mode(0o600);
259    match options.open(path) {
260        Ok(_) => return Ok(true),
261        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => return Ok(false),
262        Err(error) => return Err(error.to_string()),
263    }
264}
265
266/// What:
267///  Create the lock file exclusively,
268///  on platforms without Unix modes.
269/// Why:
270///   Private permissions are not applied there;
271///  the identity check still refuses the lock.
272///
273/// In TS you'd write (pseudocode):
274/// ```ts
275/// open(lockPath, O_CREAT | O_EXCL | O_RDWR);
276/// ```
277// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
278#[cfg(not(unix))]
279fn create_exclusive(path: &Path) -> Result<bool, String> {
280    let mut options: std::fs::OpenOptions = std::fs::OpenOptions::new();
281    options.write(true).create_new(true);
282    match options.open(path) {
283        Ok(_) => return Ok(true),
284        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => return Ok(false),
285        Err(error) => return Err(error.to_string()),
286    }
287}
288
289/// What:
290///  The identity of a freshly created lock file.
291/// Why:
292///   The journal records it;
293///  a platform without device and inode numbers records nothing.
294///
295/// In TS you'd write (pseudocode):
296/// ```ts
297/// { file: { device: String(stat.dev), inode: String(stat.ino) }, fsId: await resolveFsId(dirname(path)) };
298/// ```
299#[cfg(unix)]
300fn created_identity(lock_path: &Path, real_index: &Path) -> Result<LockIdentity, String> {
301    use std::os::unix::fs::MetadataExt;
302    let metadata: std::fs::Metadata = match std::fs::symlink_metadata(lock_path) {
303        Ok(found) => found,
304        Err(error) => return Err(error.to_string()),
305    };
306    let directory: &Path = real_index.parent().unwrap_or(Path::new("."));
307    let fs_id: String = match filesystem_identity(directory) {
308        Some(found) => found,
309        None => return Err(String::from("the filesystem identity of the worktree is unavailable")),
310    };
311    return Ok(LockIdentity {
312        file: FileIdentity {
313            device: metadata.dev().to_string(),
314            inode: metadata.ino().to_string(),
315        },
316        fs_id,
317    });
318}
319
320/// What:
321///  On platforms without device and inode numbers,
322///  no identity is recorded and the landing stops.
323/// Why:
324///   The Windows volume serial is not exposed by the standard library,
325///  as the design notes say.
326///
327/// In TS you'd write (pseudocode):
328/// ```ts
329/// throw new TypeError('filesystem identity unavailable');
330/// ```
331// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
332#[cfg(not(unix))]
333fn created_identity(lock_path: &Path, _real_index: &Path) -> Result<LockIdentity, String> {
334    return Err(format!("the index lock identity cannot be recorded here: {}", lock_path.display()));
335}
336
337/// What:
338///  Remove the PID file when it still names this process.
339/// Why:
340///   Another process may have announced itself meanwhile.
341///
342/// In TS you'd write (pseudocode):
343/// ```ts
344/// if ((await readFile(pidPath, 'utf8')) === `pid ${process.pid}\n`) await rm(pidPath);
345/// ```
346fn remove_own_pid_file(pid_path: &Path) -> Result<(), String> {
347    let own: String = format!("pid {}\n", std::process::id());
348    match std::fs::read_to_string(pid_path) {
349        Ok(text) => {
350            if text == own && let Err(error) = std::fs::remove_file(pid_path) {
351                return Err(error.to_string());
352            }
353            return Ok(());
354        }
355        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
356        Err(error) => return Err(error.to_string()),
357    }
358}
359
360/// What:
361///  Create the lock exclusively,
362///  waiting for a foreign holder under the classification rules,
363///  and read the identity of the file created.
364/// Why:
365///   `openLockWaiting` plus the identity step of the landing;
366///  a lock whose identity cannot be recorded is removed again,
367///  because nothing proves it is ours.
368///
369/// In TS you'd write (pseudocode):
370/// ```ts
371/// const handle = await openLockWaiting({ realIndexPath, timeoutMs });
372/// ```
373fn create_identified_lock(
374    real_index: &Path,
375    timeout_ms: u64,
376    consequence: &str,
377) -> Result<(PathBuf, LockIdentity), CoordinationFailure> {
378    let lock_path: PathBuf = real_lock_path(real_index);
379    loop {
380        match create_exclusive(lock_path.as_path()) {
381            Ok(true) => break,
382            Ok(false) => wait_for_lock(real_index, timeout_ms, consequence)?,
383            Err(reason) => return Err(CoordinationFailure::Stop(reason)),
384        }
385    }
386    match created_identity(lock_path.as_path(), real_index) {
387        Ok(identity) => return Ok((lock_path, identity)),
388        Err(reason) => {
389            let _ = std::fs::remove_file(lock_path.as_path());
390            return Err(CoordinationFailure::Stop(reason));
391        }
392    }
393}
394
395/// What:
396///  Take the real index lock for a landing so that its journal record exists before the lock does:
397///  create a candidate file in the transaction directory,
398///  journal the candidate's identity,
399///  then publish the candidate as `index.lock` by a hard link,
400///  which is as exclusive as `O_EXCL` and gives the lock the candidate's inode.
401///  `Ok(None)` means the filesystem refused the link and nothing was left behind,
402///  so the caller creates the lock in place and journals it afterwards.
403/// Why:
404///   A kill between the creation of `index.lock` and the write of its record leaves a lock that nothing proves
405///  ours, which every later command refuses to remove (the lock may be Git's); no window like that remains when the
406///  record comes first. A candidate that outlives a kill lies in the transaction directory, which recovery removes.
407///
408/// In TS you'd write (pseudocode):
409/// ```ts
410/// await writeCandidate(); await journalIdentity(candidate); await link(candidate, indexLock); await rm(candidate);
411/// ```
412fn create_journaled_lock(
413    real_index: &Path,
414    transaction_directory: &Path,
415    attempt: i64,
416    timeout_ms: u64,
417    consequence: &str,
418) -> Result<Option<(PathBuf, LockIdentity)>, CoordinationFailure> {
419    let lock_path: PathBuf = real_lock_path(real_index);
420    let candidate: PathBuf = transaction_directory.join(format!("index-lock-{attempt}.candidate"));
421    match create_exclusive(candidate.as_path()) {
422        Ok(true) => {}
423        Ok(false) => return Err(CoordinationFailure::Stop(format!("{} already exists", candidate.display()))),
424        Err(reason) => return Err(CoordinationFailure::Stop(reason)),
425    }
426    let identity: LockIdentity = match created_identity(candidate.as_path(), real_index) {
427        Ok(found) => found,
428        Err(reason) => {
429            let _ = std::fs::remove_file(candidate.as_path());
430            return Err(CoordinationFailure::Stop(reason));
431        }
432    };
433    if let Err(reason) = write_lock_record(transaction_directory, attempt, &identity) {
434        let _ = std::fs::remove_file(candidate.as_path());
435        return Err(CoordinationFailure::Stop(reason));
436    }
437    loop {
438        match std::fs::hard_link(candidate.as_path(), lock_path.as_path()) {
439            Ok(()) => break,
440            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
441                if let Err(failure) = wait_for_lock(real_index, timeout_ms, consequence) {
442                    // The lock was never taken, so nothing is journaled for it.
443                    let _ = std::fs::remove_file(candidate.as_path());
444                    let _ = std::fs::remove_file(transaction_directory.join(index_lock_record_filename(attempt)));
445                    return Err(failure);
446                }
447            }
448            Err(error) => {
449                debug(
450                    TAG,
451                    format!("the filesystem refused to link the lock candidate ({error}); creating the lock in place").as_str(),
452                );
453                let _ = std::fs::remove_file(candidate.as_path());
454                let record: PathBuf = transaction_directory.join(index_lock_record_filename(attempt));
455                if let Err(removal) = std::fs::remove_file(record.as_path()) {
456                    return Err(CoordinationFailure::Stop(format!("removing {} failed: {removal}", record.display())));
457                }
458                return Ok(None);
459            }
460        }
461    }
462    // The lock now has its own name; the candidate's name was only the way in.
463    if let Err(error) = std::fs::remove_file(candidate.as_path()) {
464        debug(TAG, format!("removing the lock candidate {} failed: {error}", candidate.display()).as_str());
465    }
466    return Ok(Some((lock_path, identity)));
467}
468
469/// What:
470///  Take the real index lock,
471///  waiting for a foreign holder under the classification rules,
472///  then
473///       record its identity and announce this process through the PID file.
474/// Why:
475///   `openLockWaiting` plus the identity and PID steps of the landing.
476///
477/// In TS you'd write (pseudocode):
478/// ```ts
479/// const handle = await openLockWaiting({ realIndexPath, timeoutMs });
480/// ```
481pub fn acquire_real_index_lock(
482    real_index: &Path,
483    timeout_ms: u64,
484    consequence: &str,
485) -> Result<RealIndexLock, CoordinationFailure> {
486    let (lock_path, identity): (PathBuf, LockIdentity) = create_identified_lock(real_index, timeout_ms, consequence)?;
487    let pid_path: PathBuf = real_pid_path(real_index);
488    if let Err(error) = write_private_file(pid_path.as_path(), format!("pid {}\n", std::process::id()).as_bytes()) {
489        let _ = std::fs::remove_file(lock_path.as_path());
490        return Err(CoordinationFailure::Stop(error.to_string()));
491    }
492    return Ok(RealIndexLock {
493        lock_path,
494        real_index: real_index.to_path_buf(),
495        pid_path,
496        identity,
497    });
498}
499
500/// What:
501///  Write `index-lock-<n>.json` for a lock just created,
502///  then make the directory entry durable.
503/// Why:
504///   Recovery can prove ownership of the lock before any landing record exists,
505///  so the record must be durable before the PID file announces the lock.
506///
507/// In TS you'd write (pseudocode):
508/// ```ts
509/// await writeJournalRecord({ directory: transactionDirectory, filename: indexLockRecordFilename(attempt), record });
510/// ```
511fn write_lock_record(transaction_directory: &Path, attempt: i64, identity: &LockIdentity) -> Result<(), String> {
512    let record: IndexLockRecord = IndexLockRecord {
513        attempt,
514        lock: identity.clone(),
515    };
516    let path: PathBuf = transaction_directory.join(index_lock_record_filename(attempt));
517    if let Err(error) = write_private_file(path.as_path(), encode_index_lock(&record).as_bytes()) {
518        return Err(format!("writing {} failed: {error}", path.display()));
519    }
520    if let Err(error) = sync_directory(transaction_directory) {
521        return Err(format!("syncing {} failed: {error}", transaction_directory.display()));
522    }
523    return Ok(());
524}
525
526/// What:
527///  Take the real index lock for a landing attempt:
528///  create it,
529///  journal its identity in `index-lock-<n>.json`,
530///  fix its mode,
531///  and only then announce this process through the PID file.
532/// Why:
533///   `acquireRealIndexLock`:
534///  the journal comes before the PID file,
535///  so a crash never leaves a PID file naming a lock that recovery cannot prove ours;
536///  an existing PID file is left alone,
537///  as the incumbent does.
538///  A failure after the lock exists removes the lock again.
539///
540/// In TS you'd write (pseudocode):
541/// ```ts
542/// await using lock = await acquireRealIndexLock({ realIndexPath, transactionDirectory, attempt, timeoutMs });
543/// ```
544pub fn acquire_landing_index_lock(
545    real_index: &Path,
546    transaction_directory: &Path,
547    attempt: i64,
548    timeout_ms: u64,
549    consequence: &str,
550) -> Result<RealIndexLock, CoordinationFailure> {
551    if let Some((lock_path, identity)) = create_journaled_lock(real_index, transaction_directory, attempt, timeout_ms, consequence)? {
552        let announced: Result<(), String> = announce_held_lock(real_index, lock_path.as_path());
553        if let Err(reason) = announced {
554            let _ = std::fs::remove_file(lock_path.as_path());
555            return Err(CoordinationFailure::Stop(reason));
556        }
557        return Ok(RealIndexLock {
558            lock_path,
559            real_index: real_index.to_path_buf(),
560            pid_path: real_pid_path(real_index),
561            identity,
562        });
563    }
564    let (lock_path, identity): (PathBuf, LockIdentity) = create_identified_lock(real_index, timeout_ms, consequence)?;
565    let announced: Result<(), String> = announce_landing_lock(real_index, transaction_directory, attempt, lock_path.as_path(), &identity);
566    if let Err(reason) = announced {
567        let _ = std::fs::remove_file(lock_path.as_path());
568        return Err(CoordinationFailure::Stop(reason));
569    }
570    return Ok(RealIndexLock {
571        lock_path,
572        real_index: real_index.to_path_buf(),
573        pid_path: real_pid_path(real_index),
574        identity,
575    });
576}
577
578/// What:
579///  The steps between creating a landing's lock and holding it:
580///  journal record,
581///  mode,
582///  PID file.
583/// Why:
584///   One failure path removes the lock,
585///  whichever step failed.
586///
587/// In TS you'd write (pseudocode):
588/// ```ts
589/// await writeJournalRecord(...); await protectPath({ path: lockPath, directory: false }); await writeOwnPidFile(pidPath);
590/// ```
591fn announce_landing_lock(
592    real_index: &Path,
593    transaction_directory: &Path,
594    attempt: i64,
595    lock_path: &Path,
596    identity: &LockIdentity,
597) -> Result<(), String> {
598    write_lock_record(transaction_directory, attempt, identity)?;
599    return announce_held_lock(real_index, lock_path);
600}
601
602/// What:
603///  The steps between holding a landing's lock and announcing it:
604///  mode,
605///  PID file.
606/// Why:
607///   The journal record is already durable, whether it was written before the lock was published or after it was
608///  created.
609///
610/// In TS you'd write (pseudocode):
611/// ```ts
612/// await protectPath({ path: lockPath, directory: false }); await writeOwnPidFile(pidPath);
613/// ```
614fn announce_held_lock(real_index: &Path, lock_path: &Path) -> Result<(), String> {
615    if let Err(error) = protect_path(lock_path, 0o600) {
616        return Err(format!("protecting {} failed: {error}", lock_path.display()));
617    }
618    let pid_path: PathBuf = real_pid_path(real_index);
619    match write_private_file(pid_path.as_path(), format!("pid {}\n", std::process::id()).as_bytes()) {
620        Ok(()) => return Ok(()),
621        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
622            debug(TAG, format!("a PID file already exists beside the lock: {}", pid_path.display()).as_str());
623            return Ok(());
624        }
625        Err(error) => return Err(format!("writing {} failed: {error}", pid_path.display())),
626    }
627}
628
629/// Real-lock controls stay out of the release executable.
630#[cfg(test)]
631#[path = "real_index_lock_tests.rs"]
632mod tests;