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