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,
397///  waiting for a foreign holder under the classification rules,
398///  then
399///       record its identity and announce this process through the PID file.
400/// Why:
401///   `openLockWaiting` plus the identity and PID steps of the landing.
402///
403/// In TS you'd write (pseudocode):
404/// ```ts
405/// const handle = await openLockWaiting({ realIndexPath, timeoutMs });
406/// ```
407pub fn acquire_real_index_lock(
408    real_index: &Path,
409    timeout_ms: u64,
410    consequence: &str,
411) -> Result<RealIndexLock, CoordinationFailure> {
412    let (lock_path, identity): (PathBuf, LockIdentity) = create_identified_lock(real_index, timeout_ms, consequence)?;
413    let pid_path: PathBuf = real_pid_path(real_index);
414    if let Err(error) = write_private_file(pid_path.as_path(), format!("pid {}\n", std::process::id()).as_bytes()) {
415        let _ = std::fs::remove_file(lock_path.as_path());
416        return Err(CoordinationFailure::Stop(error.to_string()));
417    }
418    return Ok(RealIndexLock {
419        lock_path,
420        real_index: real_index.to_path_buf(),
421        pid_path,
422        identity,
423    });
424}
425
426/// What:
427///  Write `index-lock-<n>.json` for a lock just created,
428///  then make the directory entry durable.
429/// Why:
430///   Recovery can prove ownership of the lock before any landing record exists,
431///  so the record must be durable before the PID file announces the lock.
432///
433/// In TS you'd write (pseudocode):
434/// ```ts
435/// await writeJournalRecord({ directory: transactionDirectory, filename: indexLockRecordFilename(attempt), record });
436/// ```
437fn write_lock_record(transaction_directory: &Path, attempt: i64, identity: &LockIdentity) -> Result<(), String> {
438    let record: IndexLockRecord = IndexLockRecord {
439        attempt,
440        lock: identity.clone(),
441    };
442    let path: PathBuf = transaction_directory.join(index_lock_record_filename(attempt));
443    if let Err(error) = write_private_file(path.as_path(), encode_index_lock(&record).as_bytes()) {
444        return Err(format!("writing {} failed: {error}", path.display()));
445    }
446    if let Err(error) = sync_directory(transaction_directory) {
447        return Err(format!("syncing {} failed: {error}", transaction_directory.display()));
448    }
449    return Ok(());
450}
451
452/// What:
453///  Take the real index lock for a landing attempt:
454///  create it,
455///  journal its identity in `index-lock-<n>.json`,
456///  fix its mode,
457///  and only then announce this process through the PID file.
458/// Why:
459///   `acquireRealIndexLock`:
460///  the journal comes before the PID file,
461///  so a crash never leaves a PID file naming a lock that recovery cannot prove ours;
462///  an existing PID file is left alone,
463///  as the incumbent does.
464///  A failure after the lock exists removes the lock again.
465///
466/// In TS you'd write (pseudocode):
467/// ```ts
468/// await using lock = await acquireRealIndexLock({ realIndexPath, transactionDirectory, attempt, timeoutMs });
469/// ```
470pub fn acquire_landing_index_lock(
471    real_index: &Path,
472    transaction_directory: &Path,
473    attempt: i64,
474    timeout_ms: u64,
475    consequence: &str,
476) -> Result<RealIndexLock, CoordinationFailure> {
477    let (lock_path, identity): (PathBuf, LockIdentity) = create_identified_lock(real_index, timeout_ms, consequence)?;
478    let announced: Result<(), String> = announce_landing_lock(real_index, transaction_directory, attempt, lock_path.as_path(), &identity);
479    if let Err(reason) = announced {
480        let _ = std::fs::remove_file(lock_path.as_path());
481        return Err(CoordinationFailure::Stop(reason));
482    }
483    return Ok(RealIndexLock {
484        lock_path,
485        real_index: real_index.to_path_buf(),
486        pid_path: real_pid_path(real_index),
487        identity,
488    });
489}
490
491/// What:
492///  The steps between creating a landing's lock and holding it:
493///  journal record,
494///  mode,
495///  PID file.
496/// Why:
497///   One failure path removes the lock,
498///  whichever step failed.
499///
500/// In TS you'd write (pseudocode):
501/// ```ts
502/// await writeJournalRecord(...); await protectPath({ path: lockPath, directory: false }); await writeOwnPidFile(pidPath);
503/// ```
504fn announce_landing_lock(
505    real_index: &Path,
506    transaction_directory: &Path,
507    attempt: i64,
508    lock_path: &Path,
509    identity: &LockIdentity,
510) -> Result<(), String> {
511    write_lock_record(transaction_directory, attempt, identity)?;
512    if let Err(error) = protect_path(lock_path, 0o600) {
513        return Err(format!("protecting {} failed: {error}", lock_path.display()));
514    }
515    let pid_path: PathBuf = real_pid_path(real_index);
516    match write_private_file(pid_path.as_path(), format!("pid {}\n", std::process::id()).as_bytes()) {
517        Ok(()) => return Ok(()),
518        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
519            debug(TAG, format!("a PID file already exists beside the lock: {}", pid_path.display()).as_str());
520            return Ok(());
521        }
522        Err(error) => return Err(format!("writing {} failed: {error}", pid_path.display())),
523    }
524}
525
526/// Real-lock controls stay out of the release executable.
527#[cfg(test)]
528#[path = "real_index_lock_tests.rs"]
529mod tests;