git_policy_cli/transaction_registry.rs
1//! What:
2//! The per-worktree registry of commit-transaction directories,
3//! `<git-dir>/cli-git-transactions/`:
4//! entry names,
5//! publication by rename,
6//! removal by
7//! rename,
8//! and the strict listing recovery reads.
9//! Why:
10//! Each transaction owns `<registry>/<uuid>/`,
11//! published only once `owner.json` is
12//! complete,
13//! so every published directory names its owner;
14//! removal renames it to a
15//! retired name first,
16//! so a reader never sees a published directory lose its owner.
17//! Both
18//! wrappers create and read the same registry (`src/policy-engine/commit-transaction-registry.ts`).
19//!
20//! In TS you'd write (pseudocode):
21//! ```ts
22//! const directory = await publishTransactionDirectory({ root, transactionId, ownerBytes });
23//! ```
24
25/// Debug diagnostics.
26use super::diagnostic_log::debug;
27/// Private creation,
28/// writing,
29/// syncing and removal.
30use super::private_storage::{
31 PRIVATE_DIRECTORY_MODE, create_private_directory, protect_path, remove_tree, sync_directory,
32 write_private_file,
33};
34/// The fail-closed recovery failure.
35use super::recovery_error::{RecoveryError, io_failure};
36/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
37use std::path::{Path, PathBuf};
38
39/// Git administrative name of the per-worktree transaction registry.
40pub const TRANSACTION_ROOT_NAME: &str = "cli-git-transactions";
41
42/// The single per-index journal directory written by builds before per-transaction journals.
43pub const LEGACY_TRANSACTION_DIRECTORY_NAME: &str = "cli-git-transaction";
44
45/// Owner record filename in every published transaction directory.
46pub const OWNER_FILENAME: &str = "owner.json";
47
48/// Suffix of an unpublished candidate whose owner record may be incomplete.
49pub const STAGING_SUFFIX: &str = ".pending";
50
51/// Suffix of a completed transaction directory whose removal is in progress.
52pub const RETIRED_SUFFIX: &str = ".retired";
53
54/// Landing lock directory name inside the registry.
55pub const LANDING_LOCK_NAME: &str = "landing.lock";
56
57/// Landing reservation lock directory name inside the registry.
58pub const RESERVATION_LOCK_NAME: &str = "reservation.lock";
59
60/// What:
61/// Whether a registry entry is one of the registry's own locks or a candidate or stale
62/// copy of one,
63/// rather than a transaction.
64/// Why:
65/// `landing.lock` and `reservation.lock` and their `.<uuid>.pending` / `.<uuid>.stale`
66/// siblings live beside transactions and are never classified as transactions.
67///
68/// In TS you'd write (pseudocode):
69/// ```ts
70/// function isRegistryLockName(name: string): boolean;
71/// ```
72pub fn is_registry_lock_name(name: &str) -> bool {
73 for lock in [LANDING_LOCK_NAME, RESERVATION_LOCK_NAME] {
74 if name == lock || name.strip_prefix(lock).is_some_and(starts_with_dot) {
75 return true;
76 }
77 }
78 return false;
79}
80
81/// What:
82/// Whether text starts with a dot.
83/// Why:
84/// A named function keeps the check out of a closure.
85///
86/// In TS you'd write (pseudocode):
87/// ```ts
88/// (rest: string) => rest.startsWith('.')
89/// ```
90fn starts_with_dot(rest: &str) -> bool {
91 return rest.starts_with('.');
92}
93
94/// What:
95/// Whether a name is exactly one canonical lowercase UUID,
96/// as `randomUUID` prints it.
97/// Why:
98/// Only such a name is a published transaction;
99/// any other name is staging,
100/// retired,
101/// or
102/// unexpected state.
103///
104/// In TS you'd write (pseudocode):
105/// ```ts
106/// function isTransactionId(name: string): boolean;
107/// ```
108pub fn is_transaction_id(name: &str) -> bool {
109 let bytes: &[u8] = name.as_bytes();
110 if bytes.len() != 36 {
111 return false;
112 }
113 for (index, byte) in bytes.iter().enumerate() {
114 let separator: bool = index == 8 || index == 13 || index == 18 || index == 23;
115 let valid: bool = if separator {
116 *byte == b'-'
117 } else {
118 byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)
119 };
120 if !valid {
121 return false;
122 }
123 }
124 return true;
125}
126
127/// What:
128/// How recovery treats one registry entry,
129/// by its name.
130/// Why:
131/// A staging candidate never started capture,
132/// a retired directory finished,
133/// and a
134/// published one must be recovered or skipped.
135///
136/// In TS you'd write (pseudocode):
137/// ```ts
138/// type TransactionRegistryEntryKind = 'transaction' | 'staging' | 'retired';
139/// ```
140#[derive(Clone, Copy, Debug, Eq, PartialEq)]
141pub enum EntryKind {
142 /// A published transaction directory.
143 Transaction,
144 /// An unpublished candidate.
145 Staging,
146 /// A completed directory whose removal was interrupted.
147 Retired,
148}
149
150/// What:
151/// One classified registry entry.
152/// Why:
153/// Recovery needs the kind,
154/// the transaction ID shared by every name form,
155/// and the path.
156///
157/// In TS you'd write (pseudocode):
158/// ```ts
159/// type TransactionRegistryEntry = { kind; transactionId: string; path: string };
160/// ```
161#[derive(Clone, Debug, Eq, PartialEq)]
162pub struct RegistryEntry {
163 /// Publication state encoded in the name.
164 pub kind: EntryKind,
165 /// Transaction ID.
166 pub transaction_id: String,
167 /// Absolute entry path.
168 pub path: PathBuf,
169}
170
171/// What:
172/// The transaction ID of a `<uuid><suffix>` name,
173/// or nothing.
174/// Why:
175/// Staging and retired names carry the ID of the transaction they belong to.
176///
177/// In TS you'd write (pseudocode):
178/// ```ts
179/// function suffixedTransactionId({ name, suffix }): string; // '' when not suffixed
180/// ```
181fn suffixed_transaction_id<'a>(name: &'a str, suffix: &str) -> Option<&'a str> {
182 let prefix: &str = name.strip_suffix(suffix)?;
183 if is_transaction_id(prefix) {
184 return Some(prefix);
185 }
186 return None;
187}
188
189/// What:
190/// Classify one entry by its name,
191/// given that it is a real directory.
192/// Why:
193/// A name that is neither a transaction,
194/// a staging candidate nor a retired directory is
195/// unexpected state,
196/// which recovery refuses to guess about.
197///
198/// In TS you'd write (pseudocode):
199/// ```ts
200/// function classifyRegistryEntry({ root, entry }): TransactionRegistryEntry; // throws when unexpected
201/// ```
202pub fn classify_entry_name(root: &Path, name: &str) -> Result<RegistryEntry, RecoveryError> {
203 let path: PathBuf = root.join(name);
204 if is_transaction_id(name) {
205 return Ok(RegistryEntry {
206 kind: EntryKind::Transaction,
207 transaction_id: String::from(name),
208 path,
209 });
210 }
211 if let Some(id) = suffixed_transaction_id(name, STAGING_SUFFIX) {
212 return Ok(RegistryEntry {
213 kind: EntryKind::Staging,
214 transaction_id: String::from(id),
215 path,
216 });
217 }
218 if let Some(id) = suffixed_transaction_id(name, RETIRED_SUFFIX) {
219 return Ok(RegistryEntry {
220 kind: EntryKind::Retired,
221 transaction_id: String::from(id),
222 path,
223 });
224 }
225 return Err(RecoveryError(format!(
226 "Unexpected transaction registry entry: {}",
227 path.display()
228 )));
229}
230
231/// What:
232/// Whether a path exists as a real directory,
233/// not a link.
234/// `Ok(false)` means nothing is there.
235/// Why:
236/// The registry and its entries are trusted only as real directories.
237///
238/// In TS you'd write (pseudocode):
239/// ```ts
240/// const metadata = await lstat(path); metadata.isDirectory() && !metadata.isSymbolicLink()
241/// ```
242fn real_directory(path: &Path) -> Result<Option<bool>, std::io::Error> {
243 match std::fs::symlink_metadata(path) {
244 Ok(metadata) => return Ok(Some(metadata.is_dir())),
245 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
246 Err(error) => return Err(error),
247 }
248}
249
250/// What:
251/// Every registry entry except the registry's own locks,
252/// sorted by name,
253/// without
254/// following links;
255/// an absent registry has none.
256/// Why:
257/// Recovery must see every transaction;
258/// a link,
259/// a file or an unexpected name in the
260/// registry fails closed with its path named.
261///
262/// In TS you'd write (pseudocode):
263/// ```ts
264/// async function listTransactionEntries(root: string): Promise<readonly TransactionRegistryEntry[]>;
265/// ```
266pub fn list_transaction_entries(root: &Path) -> Result<Vec<RegistryEntry>, RecoveryError> {
267 match real_directory(root) {
268 Ok(None) => return Ok(Vec::new()),
269 Ok(Some(true)) => {}
270 Ok(Some(false)) => {
271 return Err(RecoveryError(format!(
272 "Unsafe transaction registry: {}",
273 root.display()
274 )));
275 }
276 Err(error) => {
277 return Err(io_failure(
278 "inspecting the transaction registry",
279 root,
280 &error,
281 ));
282 }
283 }
284 let listing: std::fs::ReadDir = match std::fs::read_dir(root) {
285 Ok(listing) => listing,
286 Err(error) => return Err(io_failure("listing the transaction registry", root, &error)),
287 };
288 // `mut` allows collecting one entry at a time.
289 let mut entries: Vec<RegistryEntry> = Vec::new();
290 for item in listing {
291 let entry: std::fs::DirEntry = match item {
292 Ok(found) => found,
293 Err(error) => return Err(io_failure("listing the transaction registry", root, &error)),
294 };
295 let name_os: std::ffi::OsString = entry.file_name();
296 // A name that is not UTF-8 cannot be a transaction or a lock.
297 let Some(name) = name_os.to_str() else {
298 return Err(RecoveryError(format!(
299 "Unexpected transaction registry entry: {}",
300 root.join(&name_os).display()
301 )));
302 };
303 if is_registry_lock_name(name) {
304 continue;
305 }
306 // `file_type` reads the entry type without following a link.
307 let is_directory: bool = match entry.file_type() {
308 Ok(kind) => kind.is_dir(),
309 Err(error) => {
310 return Err(io_failure(
311 "inspecting a registry entry",
312 entry.path().as_path(),
313 &error,
314 ));
315 }
316 };
317 if !is_directory {
318 return Err(RecoveryError(format!(
319 "Unsafe transaction recovery directory: {}",
320 entry.path().display()
321 )));
322 }
323 entries.push(classify_entry_name(root, name)?);
324 }
325 entries.sort_by(compare_entry_paths);
326 return Ok(entries);
327}
328
329/// What:
330/// Order two entries by path bytes.
331/// Why:
332/// A named comparison keeps the sort free of closures;
333/// the order only needs to be
334/// stable,
335/// because recovery orders transactions by their owners' creation times.
336///
337/// In TS you'd write (pseudocode):
338/// ```ts
339/// (left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0
340/// ```
341fn compare_entry_paths(left: &RegistryEntry, right: &RegistryEntry) -> std::cmp::Ordering {
342 return left.path.cmp(&right.path);
343}
344
345/// What:
346/// Create the registry when absent and prove it is a real directory under a canonical
347/// parent.
348/// Why:
349/// A link anywhere on the way could redirect transaction state outside the Git
350/// directory;
351/// the incumbent refuses a non-canonical parent and a linked registry.
352///
353/// In TS you'd write (pseudocode):
354/// ```ts
355/// async function ensureTransactionRoot(root: string): Promise<void>;
356/// ```
357pub fn ensure_transaction_root(root: &Path) -> Result<(), RecoveryError> {
358 let (Some(parent), Some(name)) = (root.parent(), root.file_name()) else {
359 return Err(RecoveryError(format!(
360 "Git transaction registry path {} has no administrative parent.",
361 root.display()
362 )));
363 };
364 let canonical: PathBuf = match std::fs::canonicalize(parent) {
365 Ok(found) => found,
366 Err(error) => return Err(io_failure("resolving the registry parent", parent, &error)),
367 };
368 if canonical.join(name) != root {
369 return Err(RecoveryError(format!(
370 "Git transaction registry path {} has a noncanonical administrative parent.",
371 root.display()
372 )));
373 }
374 match create_private_directory(root) {
375 Ok(()) => {
376 if let Err(error) = protect_path(root, PRIVATE_DIRECTORY_MODE) {
377 return Err(io_failure(
378 "protecting the transaction registry",
379 root,
380 &error,
381 ));
382 }
383 if let Err(error) = sync_directory(canonical.as_path()) {
384 return Err(io_failure(
385 "syncing the registry parent",
386 canonical.as_path(),
387 &error,
388 ));
389 }
390 }
391 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
392 Err(error) => {
393 return Err(io_failure(
394 "creating the transaction registry",
395 root,
396 &error,
397 ));
398 }
399 }
400 match real_directory(root) {
401 Ok(Some(true)) => return Ok(()),
402 Ok(_) => {
403 return Err(RecoveryError(format!(
404 "Unsafe transaction registry: {}",
405 root.display()
406 )));
407 }
408 Err(error) => {
409 return Err(io_failure(
410 "inspecting the transaction registry",
411 root,
412 &error,
413 ));
414 }
415 }
416}
417
418/// What:
419/// Publish one transaction directory holding its complete owner record.
420/// Returns the published directory.
421/// Why:
422/// The directory is built under its staging name,
423/// made durable,
424/// then renamed;
425/// a
426/// failure before the rename removes the candidate,
427/// so nothing half-built is published.
428///
429/// In TS you'd write (pseudocode):
430/// ```ts
431/// async function publishTransactionDirectory({ root, transactionId, ownerBytes }): Promise<string>;
432/// ```
433pub fn publish_transaction_directory(
434 root: &Path,
435 transaction_id: &str,
436 owner_bytes: &[u8],
437) -> Result<PathBuf, RecoveryError> {
438 if !is_transaction_id(transaction_id) {
439 return Err(RecoveryError(format!(
440 "Malformed commit transaction ID: {transaction_id}"
441 )));
442 }
443 let staging: PathBuf = root.join(format!("{transaction_id}{STAGING_SUFFIX}"));
444 let directory: PathBuf = root.join(transaction_id);
445 if let Err(error) = create_private_directory(staging.as_path()) {
446 return Err(io_failure(
447 "creating the transaction candidate",
448 staging.as_path(),
449 &error,
450 ));
451 }
452 if let Err(failure) = fill_candidate(staging.as_path(), owner_bytes) {
453 // The candidate was never published; removing it is best effort.
454 let _ = remove_tree(staging.as_path());
455 return Err(failure);
456 }
457 if let Err(error) = std::fs::rename(staging.as_path(), directory.as_path()) {
458 let _ = remove_tree(staging.as_path());
459 return Err(io_failure(
460 "publishing the transaction directory",
461 directory.as_path(),
462 &error,
463 ));
464 }
465 if let Err(error) = sync_directory(root) {
466 return Err(io_failure("syncing the transaction registry", root, &error));
467 }
468 return Ok(directory);
469}
470
471/// What:
472/// Protect a fresh candidate and write its owner record durably.
473/// Why:
474/// Everything before the publishing rename,
475/// so a failure can remove the candidate.
476///
477/// In TS you'd write (pseudocode):
478/// ```ts
479/// await protectPath(staging); await writePrivateFile(owner); await syncDirectory(staging);
480/// ```
481fn fill_candidate(staging: &Path, owner_bytes: &[u8]) -> Result<(), RecoveryError> {
482 if let Err(error) = protect_path(staging, PRIVATE_DIRECTORY_MODE) {
483 return Err(io_failure(
484 "protecting the transaction candidate",
485 staging,
486 &error,
487 ));
488 }
489 let owner: PathBuf = staging.join(OWNER_FILENAME);
490 if let Err(error) = write_private_file(owner.as_path(), owner_bytes) {
491 return Err(io_failure(
492 "writing the transaction owner record",
493 owner.as_path(),
494 &error,
495 ));
496 }
497 if let Err(error) = sync_directory(staging) {
498 return Err(io_failure(
499 "syncing the transaction candidate",
500 staging,
501 &error,
502 ));
503 }
504 return Ok(());
505}
506
507/// What:
508/// Remove a transaction directory:
509/// a registry directory is renamed to its retired name
510/// first,
511/// any other directory (the legacy journal) is removed in place.
512/// Why:
513/// A reader listing the registry must never see a published directory without its
514/// owner record;
515/// the retired name is ignored by every reader.
516///
517/// In TS you'd write (pseudocode):
518/// ```ts
519/// async function removeTransactionDirectory(directory: string): Promise<void>;
520/// ```
521pub fn remove_transaction_directory(directory: &Path) -> Result<(), RecoveryError> {
522 let Some(parent) = directory.parent() else {
523 return Err(RecoveryError(format!(
524 "Transaction directory {} has no parent.",
525 directory.display()
526 )));
527 };
528 let in_registry: bool = parent.file_name().is_some_and(is_registry_name)
529 && directory
530 .file_name()
531 .and_then(std::ffi::OsStr::to_str)
532 .is_some_and(is_transaction_id);
533 if !in_registry {
534 if let Err(error) = remove_tree(directory) {
535 return Err(io_failure(
536 "removing the transaction directory",
537 directory,
538 &error,
539 ));
540 }
541 return sync_parent(parent);
542 }
543 // `as_os_str().to_owned()` copies the raw path so the suffix can be appended.
544 let mut retired_name: std::ffi::OsString = directory.as_os_str().to_owned();
545 retired_name.push(RETIRED_SUFFIX);
546 let retired: PathBuf = PathBuf::from(retired_name);
547 if let Err(error) = std::fs::rename(directory, retired.as_path()) {
548 // Another recoverer of the same dead transaction retired it first: the end state is the same.
549 if error.kind() == std::io::ErrorKind::NotFound {
550 debug(
551 "removeTransactionDirectory",
552 format!("transaction already retired by another recoverer: {}", directory.display()).as_str(),
553 );
554 return Ok(());
555 }
556 return Err(io_failure(
557 "retiring the transaction directory",
558 directory,
559 &error,
560 ));
561 }
562 sync_parent(parent)?;
563 if let Err(error) = remove_tree(retired.as_path()) {
564 return Err(io_failure(
565 "removing the retired transaction",
566 retired.as_path(),
567 &error,
568 ));
569 }
570 return sync_parent(parent);
571}
572
573/// What:
574/// Whether a directory name is the registry's name.
575/// Why:
576/// A named function keeps the check out of a closure.
577///
578/// In TS you'd write (pseudocode):
579/// ```ts
580/// (name: string) => name === TRANSACTION_ROOT_NAME
581/// ```
582fn is_registry_name(name: &std::ffi::OsStr) -> bool {
583 return name == TRANSACTION_ROOT_NAME;
584}
585
586/// What:
587/// Sync a parent directory,
588/// naming it on failure.
589/// Why:
590/// Removal steps sync the registry after each rename and deletion.
591///
592/// In TS you'd write (pseudocode):
593/// ```ts
594/// await syncDirectory(parent);
595/// ```
596fn sync_parent(parent: &Path) -> Result<(), RecoveryError> {
597 if let Err(error) = sync_directory(parent) {
598 return Err(io_failure(
599 "syncing the transaction registry",
600 parent,
601 &error,
602 ));
603 }
604 return Ok(());
605}
606
607/// Registry controls stay out of the release executable.
608#[cfg(test)]
609#[path = "transaction_registry_tests.rs"]
610mod tests;