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#[cfg(not(unix))]
239fn same_file_identity(path: &Path, _identity: &LockIdentity) -> Result<(), String> {
240    return Err(format!("the index lock identity cannot be checked here: {}", path.display()));
241}
242
243/// What:
244///  Create the lock file exclusively with private permissions.
245/// Why:
246///   `O_EXCL` is the mutual exclusion;
247///  an existing file means another owner holds the lock.
248///
249/// In TS you'd write (pseudocode):
250/// ```ts
251/// open(lockPath, O_CREAT | O_EXCL | O_RDWR, 0o600);
252/// ```
253#[cfg(unix)]
254fn create_exclusive(path: &Path) -> Result<bool, String> {
255    use std::os::unix::fs::OpenOptionsExt;
256    let mut options: std::fs::OpenOptions = std::fs::OpenOptions::new();
257    options.write(true).create_new(true).mode(0o600);
258    match options.open(path) {
259        Ok(_) => return Ok(true),
260        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => return Ok(false),
261        Err(error) => return Err(error.to_string()),
262    }
263}
264
265/// What:
266///  Create the lock file exclusively,
267///  on platforms without Unix modes.
268/// Why:
269///   Private permissions are not applied there;
270///  the identity check still refuses the lock.
271///
272/// In TS you'd write (pseudocode):
273/// ```ts
274/// open(lockPath, O_CREAT | O_EXCL | O_RDWR);
275/// ```
276#[cfg(not(unix))]
277fn create_exclusive(path: &Path) -> Result<bool, String> {
278    let mut options: std::fs::OpenOptions = std::fs::OpenOptions::new();
279    options.write(true).create_new(true);
280    match options.open(path) {
281        Ok(_) => return Ok(true),
282        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => return Ok(false),
283        Err(error) => return Err(error.to_string()),
284    }
285}
286
287/// What:
288///  The identity of a freshly created lock file.
289/// Why:
290///   The journal records it;
291///  a platform without device and inode numbers records nothing.
292///
293/// In TS you'd write (pseudocode):
294/// ```ts
295/// { file: { device: String(stat.dev), inode: String(stat.ino) }, fsId: await resolveFsId(dirname(path)) };
296/// ```
297#[cfg(unix)]
298fn created_identity(lock_path: &Path, real_index: &Path) -> Result<LockIdentity, String> {
299    use std::os::unix::fs::MetadataExt;
300    let metadata: std::fs::Metadata = match std::fs::symlink_metadata(lock_path) {
301        Ok(found) => found,
302        Err(error) => return Err(error.to_string()),
303    };
304    let directory: &Path = real_index.parent().unwrap_or(Path::new("."));
305    let fs_id: String = match filesystem_identity(directory) {
306        Some(found) => found,
307        None => return Err(String::from("the filesystem identity of the worktree is unavailable")),
308    };
309    return Ok(LockIdentity {
310        file: FileIdentity {
311            device: metadata.dev().to_string(),
312            inode: metadata.ino().to_string(),
313        },
314        fs_id,
315    });
316}
317
318/// What:
319///  On platforms without device and inode numbers,
320///  no identity is recorded and the landing stops.
321/// Why:
322///   The Windows volume serial is not exposed by the standard library,
323///  as the design notes say.
324///
325/// In TS you'd write (pseudocode):
326/// ```ts
327/// throw new TypeError('filesystem identity unavailable');
328/// ```
329#[cfg(not(unix))]
330fn created_identity(lock_path: &Path, _real_index: &Path) -> Result<LockIdentity, String> {
331    return Err(format!("the index lock identity cannot be recorded here: {}", lock_path.display()));
332}
333
334/// What:
335///  Remove the PID file when it still names this process.
336/// Why:
337///   Another process may have announced itself meanwhile.
338///
339/// In TS you'd write (pseudocode):
340/// ```ts
341/// if ((await readFile(pidPath, 'utf8')) === `pid ${process.pid}\n`) await rm(pidPath);
342/// ```
343fn remove_own_pid_file(pid_path: &Path) -> Result<(), String> {
344    let own: String = format!("pid {}\n", std::process::id());
345    match std::fs::read_to_string(pid_path) {
346        Ok(text) => {
347            if text == own && let Err(error) = std::fs::remove_file(pid_path) {
348                return Err(error.to_string());
349            }
350            return Ok(());
351        }
352        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
353        Err(error) => return Err(error.to_string()),
354    }
355}
356
357/// What:
358///  Create the lock exclusively,
359///  waiting for a foreign holder under the classification rules,
360///  and read the identity of the file created.
361/// Why:
362///   `openLockWaiting` plus the identity step of the landing;
363///  a lock whose identity cannot be recorded is removed again,
364///  because nothing proves it is ours.
365///
366/// In TS you'd write (pseudocode):
367/// ```ts
368/// const handle = await openLockWaiting({ realIndexPath, timeoutMs });
369/// ```
370fn create_identified_lock(
371    real_index: &Path,
372    timeout_ms: u64,
373    consequence: &str,
374) -> Result<(PathBuf, LockIdentity), CoordinationFailure> {
375    let lock_path: PathBuf = real_lock_path(real_index);
376    loop {
377        match create_exclusive(lock_path.as_path()) {
378            Ok(true) => break,
379            Ok(false) => wait_for_lock(real_index, timeout_ms, consequence)?,
380            Err(reason) => return Err(CoordinationFailure::Stop(reason)),
381        }
382    }
383    match created_identity(lock_path.as_path(), real_index) {
384        Ok(identity) => return Ok((lock_path, identity)),
385        Err(reason) => {
386            let _ = std::fs::remove_file(lock_path.as_path());
387            return Err(CoordinationFailure::Stop(reason));
388        }
389    }
390}
391
392/// What:
393///  Take the real index lock,
394///  waiting for a foreign holder under the classification rules,
395///  then
396///       record its identity and announce this process through the PID file.
397/// Why:
398///   `openLockWaiting` plus the identity and PID steps of the landing.
399///
400/// In TS you'd write (pseudocode):
401/// ```ts
402/// const handle = await openLockWaiting({ realIndexPath, timeoutMs });
403/// ```
404pub fn acquire_real_index_lock(
405    real_index: &Path,
406    timeout_ms: u64,
407    consequence: &str,
408) -> Result<RealIndexLock, CoordinationFailure> {
409    let (lock_path, identity): (PathBuf, LockIdentity) = create_identified_lock(real_index, timeout_ms, consequence)?;
410    let pid_path: PathBuf = real_pid_path(real_index);
411    if let Err(error) = write_private_file(pid_path.as_path(), format!("pid {}\n", std::process::id()).as_bytes()) {
412        let _ = std::fs::remove_file(lock_path.as_path());
413        return Err(CoordinationFailure::Stop(error.to_string()));
414    }
415    return Ok(RealIndexLock {
416        lock_path,
417        real_index: real_index.to_path_buf(),
418        pid_path,
419        identity,
420    });
421}
422
423/// What:
424///  Write `index-lock-<n>.json` for a lock just created,
425///  then make the directory entry durable.
426/// Why:
427///   Recovery can prove ownership of the lock before any landing record exists,
428///  so the record must be durable before the PID file announces the lock.
429///
430/// In TS you'd write (pseudocode):
431/// ```ts
432/// await writeJournalRecord({ directory: transactionDirectory, filename: indexLockRecordFilename(attempt), record });
433/// ```
434fn write_lock_record(transaction_directory: &Path, attempt: i64, identity: &LockIdentity) -> Result<(), String> {
435    let record: IndexLockRecord = IndexLockRecord {
436        attempt,
437        lock: identity.clone(),
438    };
439    let path: PathBuf = transaction_directory.join(index_lock_record_filename(attempt));
440    if let Err(error) = write_private_file(path.as_path(), encode_index_lock(&record).as_bytes()) {
441        return Err(format!("writing {} failed: {error}", path.display()));
442    }
443    if let Err(error) = sync_directory(transaction_directory) {
444        return Err(format!("syncing {} failed: {error}", transaction_directory.display()));
445    }
446    return Ok(());
447}
448
449/// What:
450///  Take the real index lock for a landing attempt:
451///  create it,
452///  journal its identity in `index-lock-<n>.json`,
453///  fix its mode,
454///  and only then announce this process through the PID file.
455/// Why:
456///   `acquireRealIndexLock`:
457///  the journal comes before the PID file,
458///  so a crash never leaves a PID file naming a lock that recovery cannot prove ours;
459///  an existing PID file is left alone,
460///  as the incumbent does.
461///  A failure after the lock exists removes the lock again.
462///
463/// In TS you'd write (pseudocode):
464/// ```ts
465/// await using lock = await acquireRealIndexLock({ realIndexPath, transactionDirectory, attempt, timeoutMs });
466/// ```
467pub fn acquire_landing_index_lock(
468    real_index: &Path,
469    transaction_directory: &Path,
470    attempt: i64,
471    timeout_ms: u64,
472    consequence: &str,
473) -> Result<RealIndexLock, CoordinationFailure> {
474    let (lock_path, identity): (PathBuf, LockIdentity) = create_identified_lock(real_index, timeout_ms, consequence)?;
475    let announced: Result<(), String> = announce_landing_lock(real_index, transaction_directory, attempt, lock_path.as_path(), &identity);
476    if let Err(reason) = announced {
477        let _ = std::fs::remove_file(lock_path.as_path());
478        return Err(CoordinationFailure::Stop(reason));
479    }
480    return Ok(RealIndexLock {
481        lock_path,
482        real_index: real_index.to_path_buf(),
483        pid_path: real_pid_path(real_index),
484        identity,
485    });
486}
487
488/// What:
489///  The steps between creating a landing's lock and holding it:
490///  journal record,
491///  mode,
492///  PID file.
493/// Why:
494///   One failure path removes the lock,
495///  whichever step failed.
496///
497/// In TS you'd write (pseudocode):
498/// ```ts
499/// await writeJournalRecord(...); await protectPath({ path: lockPath, directory: false }); await writeOwnPidFile(pidPath);
500/// ```
501fn announce_landing_lock(
502    real_index: &Path,
503    transaction_directory: &Path,
504    attempt: i64,
505    lock_path: &Path,
506    identity: &LockIdentity,
507) -> Result<(), String> {
508    write_lock_record(transaction_directory, attempt, identity)?;
509    if let Err(error) = protect_path(lock_path, 0o600) {
510        return Err(format!("protecting {} failed: {error}", lock_path.display()));
511    }
512    let pid_path: PathBuf = real_pid_path(real_index);
513    match write_private_file(pid_path.as_path(), format!("pid {}\n", std::process::id()).as_bytes()) {
514        Ok(()) => return Ok(()),
515        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
516            debug(TAG, format!("a PID file already exists beside the lock: {}", pid_path.display()).as_str());
517            return Ok(());
518        }
519        Err(error) => return Err(format!("writing {} failed: {error}", pid_path.display())),
520    }
521}
522
523/// Real-lock controls stay out of the release executable.
524#[cfg(test)]
525#[path = "real_index_lock_tests.rs"]
526mod tests;