Skip to main content

git_policy_cli/
recovery_completion.rs

1//! What:
2//!  Deciding whether a dead transaction's landing attempt landed,
3//!  and installing or
4//!       recognizing its recorded post-index.
5//! Why:
6//!  Before `ref-updated.json` the only proof of a landing is the nonce entry in the target's
7//!      reflog;
8//!  missing or contradictory evidence fails closed.
9//!  Artifacts are pinned under
10//!      stable names by inode before use (`src/policy-engine/commit-transaction-recovery-completion.ts`).
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! if (await commitLanded({ ... })) await completeIndex({ directory, preparing, landing });
15//! ```
16
17/// The fail-closed recovery failure.
18use super::recovery_error::RecoveryError;
19/// Exact reads,
20///  owned links,
21///  lock release and index installation.
22use super::recovery_files::{
23    create_owned_file_link, files_equal, install_recovered_index, read_recovery_file,
24    recovery_path_exists, release_owned_lock, remove_file_if_present,
25};
26/// The reflog nonce search.
27use super::recovery_reflog::list_nonce_reflog_oids;
28/// The failure of a journal record that cannot be read, with its path and ways out named.
29use super::recovery_record_failure::{RecordKind, name_record_failure};
30/// The check of the snapshots' bytes.
31use super::recovery_snapshot::{SnapshotRole, verify_snapshot};
32/// Running real Git.
33use super::transaction_git::{GitContext, GitRequest, run_git};
34/// Journal records and artifact names.
35use super::transaction_journal::{
36    FileIdentity, INDEX_INSTALLED_FILENAME, LandingRecord, PreparingRecord, REF_UPDATED_FILENAME,
37    post_index_filename, pre_landing_index_filename,
38};
39/// The `ref-updated.json` parser.
40use super::transaction_journal_parse::parse_ref_updated;
41/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
42use std::path::{Path, PathBuf};
43
44/// What:
45///  What recovery did to the real index of a landed commit.
46/// Why:
47///   Reported per transaction,
48///  as the incumbent's recovery action names.
49///
50/// In TS you'd write (pseudocode):
51/// ```ts
52/// type IndexCompletion = 'index-installed' | 'already-installed';
53/// ```
54#[derive(Clone, Copy, Debug, Eq, PartialEq)]
55pub enum IndexCompletion {
56    /// The recorded post-index was installed now.
57    Installed,
58    /// The post-index was installed before the owner died.
59    AlreadyInstalled,
60}
61
62/// What:
63///  The nonce-bearing reflog subject prefix of a transaction.
64/// Why:
65///   The landing writes `commit (cli-git <nonce>): <subject>`.
66///
67/// In TS you'd write (pseudocode):
68/// ```ts
69/// `commit (cli-git ${transactionId}):`
70/// ```
71pub fn nonce_subject_prefix(transaction_id: &str) -> String {
72    return format!("commit (cli-git {transaction_id}):");
73}
74
75/// What:
76///  Decide whether a commit landing record's attempt landed.
77///       `Ok(true)` means landed;
78///  `Ok(false)` means the target never moved to it.
79/// Why:
80///   `commitLanded`:
81///  `ref-updated.json` is exact proof;
82///  otherwise one nonce entry naming the
83///       recorded commit is proof,
84///  several are contradictory,
85///  one naming another commit is
86///       contradictory,
87///  and none while the commit is reachable anyway is contradictory too.
88///
89/// In TS you'd write (pseudocode):
90/// ```ts
91/// async function commitLanded({ gitPath, cwd, directory, preparing, landing }): Promise<boolean>;
92/// ```
93pub fn commit_landed(
94    context: &GitContext,
95    cwd: &Path,
96    directory: &Path,
97    preparing: &PreparingRecord,
98    landing: &LandingRecord,
99) -> Result<bool, RecoveryError> {
100    let marker: PathBuf = directory.join(REF_UPDATED_FILENAME);
101    let new_oid: &str = landing.new_oid.as_deref().unwrap_or("undefined");
102    if recovery_path_exists(marker.as_path())? {
103        let landed: String = parse_ref_updated(read_recovery_file(marker.as_path())?.as_slice())
104            .map_err(|error| { return name_record_failure(error, marker.as_path(), RecordKind::RefUpdated); })?;
105        if landing.new_oid.as_deref() != Some(landed.as_str()) {
106            return Err(RecoveryError(format!(
107                "Landed marker names another commit than the landing record: {}",
108                directory.display()
109            )));
110        }
111        return Ok(true);
112    }
113    let oids: Vec<String> = list_nonce_reflog_oids(
114        context,
115        cwd,
116        preparing.target_ref.as_str(),
117        nonce_subject_prefix(preparing.transaction_id.as_str()).as_str(),
118    )?;
119    if oids.len() > 1 {
120        return Err(RecoveryError(format!(
121            "{} reflog names several commits for transaction {}; recovery retained at {}",
122            preparing.target_ref,
123            preparing.transaction_id,
124            directory.display()
125        )));
126    }
127    if let Some(found) = oids.first() {
128        if found != new_oid {
129            return Err(RecoveryError(format!(
130                "{} reflog nonce names {found}, not the recorded {new_oid}; recovery retained at {}",
131                preparing.target_ref,
132                directory.display()
133            )));
134        }
135        return Ok(true);
136    }
137    let reachable: bool = match run_git(
138        context,
139        &GitRequest::new(
140            cwd,
141            &[
142                "merge-base",
143                "--is-ancestor",
144                new_oid,
145                preparing.target_ref.as_str(),
146            ],
147        ),
148    ) {
149        Ok(output) => output.succeeded(),
150        Err(_) => false,
151    };
152    if reachable {
153        return Err(RecoveryError(format!(
154            "{} contains the transaction commit {new_oid} but its reflog lacks the nonce entry; \
155             recovery retained at {}",
156            preparing.target_ref,
157            directory.display()
158        )));
159    }
160    return Ok(false);
161}
162
163/// What:
164///  Pin a transaction artifact under `<name>.recovery` after proving its recorded identity.
165/// Why:
166///   `stabilizeArtifact`:
167///  the stable name keeps the exact inode even if the original name
168///       is replaced meanwhile.
169///
170/// In TS you'd write (pseudocode):
171/// ```ts
172/// async function stabilizeArtifact({ directory, filename, identity }): Promise<string>;
173/// ```
174fn stabilize_artifact(
175    directory: &Path,
176    filename: &str,
177    identity: &FileIdentity,
178) -> Result<PathBuf, RecoveryError> {
179    let stable: PathBuf = directory.join(format!("{filename}.recovery"));
180    remove_file_if_present(stable.as_path())?;
181    create_owned_file_link(
182        directory.join(filename).as_path(),
183        stable.as_path(),
184        identity.device.as_str(),
185        identity.inode.as_str(),
186    )?;
187    return Ok(stable);
188}
189
190/// What:
191///  Install or recognize the recorded post-index after a landing.
192/// Why:
193///   `completeIndex`:
194///  a present `index-installed` marker or a real index already equal to
195///       the post-index means done (the owned lock is released);
196///  otherwise the real index must
197///       still equal the pre-landing snapshot,
198///  or recovery fails closed.
199///
200/// In TS you'd write (pseudocode):
201/// ```ts
202/// async function completeIndex({ directory, preparing, landing }): Promise<'index-installed' | 'already-installed'>;
203/// ```
204pub fn complete_index(
205    directory: &Path,
206    preparing: &PreparingRecord,
207    landing: &LandingRecord,
208) -> Result<IndexCompletion, RecoveryError> {
209    let pre_landing: PathBuf = stabilize_artifact(
210        directory,
211        pre_landing_index_filename(landing.attempt).as_str(),
212        &landing.pre_landing_index,
213    )?;
214    let post: PathBuf = stabilize_artifact(
215        directory,
216        post_index_filename(landing.attempt).as_str(),
217        &landing.post_index,
218    )?;
219    let real_index: &Path = Path::new(preparing.real_index_path.as_str());
220    let lock_path: PathBuf = PathBuf::from(format!("{}.lock", preparing.real_index_path));
221    if recovery_path_exists(directory.join(INDEX_INSTALLED_FILENAME).as_path())?
222        || files_equal(post.as_path(), real_index)?
223    {
224        release_owned_lock(&landing.lock, lock_path.as_path())?;
225        return Ok(IndexCompletion::AlreadyInstalled);
226    }
227    // Only the install path trusts the snapshots' bytes: a landing whose install already happened needs neither.
228    verify_snapshot(
229        directory,
230        preparing,
231        landing,
232        pre_landing.as_path(),
233        SnapshotRole::PreLanding,
234        landing.pre_landing_digest,
235    )?;
236    verify_snapshot(directory, preparing, landing, post.as_path(), SnapshotRole::Post, landing.post_digest)?;
237    if !files_equal(pre_landing.as_path(), real_index)? {
238        return Err(RecoveryError(format!(
239            "Real index no longer matches the pre-landing snapshot; recovery retained at {}",
240            directory.display()
241        )));
242    }
243    install_recovered_index(
244        lock_path.as_path(),
245        real_index,
246        post.as_path(),
247        &landing.lock,
248    )?;
249    return Ok(IndexCompletion::Installed);
250}
251
252/// Landed-or-not and index completion controls stay out of the release executable.
253#[cfg(test)]
254#[path = "recovery_completion_tests.rs"]
255mod tests;