Skip to main content

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;