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/// 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;