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;