Skip to main content

git_policy_cli/
direct_fix_install.rs

1//! What:
2//!  Install the corrections a converged `git cli-git fix` settled on into the
3//!       worktree,
4//!  all of them or none,
5//!  and never touch the index.
6//! Why:
7//!  The installed wrapper's `direct-fix-install.ts` decides this protocol:
8//!  every
9//!      corrected file must still hold the bytes the fix read,
10//!  each replacement is written
11//!      beside its file and renamed over it,
12//!  a copy of the old file is kept until the end,
13//!      and the real index must hold the same bytes afterwards.
14//!  Any failure restores every
15//!      file already replaced.
16//!  Failure messages name no file,
17//!  because a pathname can hold
18//!      the text a scan exists to keep out of output.
19//!
20//! In TS you'd write (pseudocode):
21//! ```ts
22//! // await installDirectFix({ scope, changedPaths, finalCandidates, originals });
23//! ```
24
25/// Import the candidate's mode,
26///  which decides the replacement's permissions.
27use super::candidate_object::CandidateMode;
28/// Import the unique name part shared with the private index directories.
29use super::candidate_private_index::unique_suffix;
30/// Import the conversion of Git pathname bytes to a native path.
31use super::git_metadata::path_from_git_bytes;
32/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
33use std::path::{Path, PathBuf};
34/// `Rc<T>` is a shared,
35///  read-only handle.
36use std::rc::Rc;
37
38/// The name prefix of the files a fix writes beside each corrected file.
39pub const INSTALL_SIBLING_PREFIX: &str = ".cli-git-direct-fix-";
40
41/// The start of every installation failure message.
42const INSTALL_FAILED: &str = "cli-git fix could not install its corrections";
43
44/// What:
45///  One file to correct:
46///  its pathname,
47///  mode,
48///  the bytes the fix read and the bytes to
49///       write.
50///  `#[derive(...)]` generates cloning,
51///  debug printing and `==`.
52/// Why:
53///   The read bytes prove nobody changed the file while the fix ran.
54///
55/// In TS you'd write (pseudocode):
56/// ```ts
57/// type InstallChange = { path: Uint8Array; mode: CandidateMode; original: Uint8Array; replacement: Uint8Array };
58/// ```
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub struct InstallChange {
61    /// The pathname,
62    ///  relative to the repository's top level,
63    ///  as Git's raw bytes.
64    pub path: Vec<u8>,
65    /// The file's mode;
66    ///  executable files stay executable.
67    pub mode: CandidateMode,
68    /// The bytes the fix read;
69    ///  the worktree file must still hold them.
70    pub original: Rc<[u8]>,
71    /// The corrected bytes.
72    pub replacement: Rc<[u8]>,
73}
74
75/// What:
76///  One prepared replacement:
77///  the file,
78///  the new bytes beside it,
79///  and a copy of the
80///       old file beside it.
81/// Why:
82///   Renaming the new file over the old one replaces it in one step,
83///  and the copy
84///       restores it if a later step fails.
85///
86/// In TS you'd write (pseudocode):
87/// ```ts
88/// type PreparedReplacement = { destination: string; prepared: string; backup: string };
89/// ```
90#[derive(Debug)]
91pub struct Replacement {
92    /// The worktree file.
93    destination: PathBuf,
94    /// The corrected bytes,
95    ///  written beside it.
96    prepared: PathBuf,
97    /// A copy of the file as it was,
98    ///  beside it.
99    backup: PathBuf,
100}
101
102/// What:
103///  An installation failure message:
104///  the shared start,
105///  then what happened.
106/// Why:
107///   Every failure reads the same way and names no file.
108///
109/// In TS you'd write (pseudocode):
110/// ```ts
111/// const installFailure = (detail: string) => `cli-git fix could not install its corrections: ${detail}`;
112/// ```
113fn install_failure(detail: &str) -> String {
114    return format!("{INSTALL_FAILED}: {detail}");
115}
116
117/// What:
118///  The real index's bytes,
119///  or nothing when it does not exist.
120///       `std::io::Result<Option<Vec<u8>>>` is "bytes,
121///  absent,
122///  or a read failure".
123/// Why:
124///   A repository without an index file has an empty index,
125///  and must still have none
126///       after the fix.
127///
128/// In TS you'd write (pseudocode):
129/// ```ts
130/// async function readIndex(path: string): Promise<Uint8Array | typeof INDEX_ABSENT>;
131/// ```
132pub fn read_index(path: &Path) -> std::io::Result<Option<Vec<u8>>> {
133    match std::fs::read(path) {
134        Ok(bytes) => return Ok(Some(bytes)),
135        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
136        Err(error) => return Err(error),
137    }
138}
139
140/// What:
141///  Remove the prepared and backup files of each replacement,
142///  ignoring files that
143///       were never written.
144/// Why:
145///   Neither may stay behind after the fix ends,
146///  whichever way it ended.
147///
148/// In TS you'd write (pseudocode):
149/// ```ts
150/// async function cleanup(replacements): Promise<void>;
151/// ```
152fn cleanup(replacements: &[Replacement]) {
153    for replacement in replacements {
154        // What: `let _ = ...` discards each removal's `Result` on purpose.
155        // Why:  A file that was never written cannot be removed, and that is fine.
156        //
157        // In TS you'd write (pseudocode):
158        // ```ts
159        // await rm(prepared, { force: true });
160        // ```
161        let _ = std::fs::remove_file(replacement.prepared.as_path());
162        let _ = std::fs::remove_file(replacement.backup.as_path());
163    }
164}
165
166/// What:
167///  Rename each installed file's backup over it,
168///  last installed first.
169///       `Result<(), String>` is success or the first restore that failed.
170/// Why:
171///   The worktree returns to the bytes it held before the fix.
172///  A backup that cannot
173///       be restored is left in place,
174///  so its bytes are not lost.
175///
176/// In TS you'd write (pseudocode):
177/// ```ts
178/// async function rollback(installed): Promise<void>;
179/// ```
180fn rollback(installed: &[Replacement]) -> Result<(), String> {
181    // `.iter().rev()` visits the installed files in reverse order.
182    for replacement in installed.iter().rev() {
183        if let Err(error) = std::fs::rename(
184            replacement.backup.as_path(),
185            replacement.destination.as_path(),
186        ) {
187            return Err(format!(
188                "restoring a corrected file failed ({error}); its original bytes remain beside it in a file whose name starts with {INSTALL_SIBLING_PREFIX}"
189            ));
190        }
191    }
192    return Ok(());
193}
194
195/// What:
196///  Write `bytes` to a new file at `path` with permissions for `mode`.
197/// Why:
198///   The file must not exist yet,
199///  so no other file is overwritten;
200///  executable
201///       files get `0755` and others `0644`,
202///  before the umask,
203///  as the installed wrapper
204///       writes them.
205///
206/// In TS you'd write (pseudocode):
207/// ```ts
208/// await writeFile(path, bytes, { mode: mode === 'executable' ? 0o755 : 0o644, flag: 'wx' });
209/// ```
210fn write_new(path: &Path, bytes: &[u8], mode: CandidateMode) -> std::io::Result<()> {
211    /// `Write` adds `write_all` to the open file.
212    use std::io::Write;
213    let mut options: std::fs::OpenOptions = std::fs::OpenOptions::new();
214    options.write(true).create_new(true);
215    #[cfg(unix)]
216    {
217        /// `OpenOptionsExt` adds `mode`,
218        ///  the permissions a new file is created with.
219        use std::os::unix::fs::OpenOptionsExt;
220        let permissions: u32 = if mode == CandidateMode::Executable {
221            0o755
222        } else {
223            0o644
224        };
225        options.mode(permissions);
226    }
227    // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
228    #[cfg(not(unix))]
229    {
230        // Only Unix records an executable bit; the mode decides nothing elsewhere.
231        let _ = mode;
232    }
233    return options.open(path)?.write_all(bytes);
234}
235
236/// What:
237///  Prepare every replacement:
238///  check the file still holds the bytes the fix read,
239///       copy it beside itself,
240///  and write the corrected bytes beside it.
241/// Why:
242///   Nothing in the worktree changes until every file is prepared,
243///  so a file that
244///       changed under the fix stops it before any file is replaced.
245///
246/// In TS you'd write (pseudocode):
247/// ```ts
248/// async function prepareReplacements(root, changes): Promise<PreparedReplacement[]>;
249/// ```
250pub fn prepare_replacements(
251    root: &Path,
252    changes: &[InstallChange],
253) -> Result<Vec<Replacement>, String> {
254    let mut replacements: Vec<Replacement> = Vec::new();
255    for change in changes {
256        let Some(relative) = path_from_git_bytes(change.path.as_slice()) else {
257            cleanup(replacements.as_slice());
258            return Err(install_failure(
259                "Git named a corrected file this system cannot represent, so no file was changed.",
260            ));
261        };
262        let destination: PathBuf = root.join(relative);
263        let current: Vec<u8> = match std::fs::read(destination.as_path()) {
264            Ok(bytes) => bytes,
265            Err(error) => {
266                cleanup(replacements.as_slice());
267                return Err(install_failure(
268                    format!("reading a corrected file failed ({error}), so no file was changed.")
269                        .as_str(),
270                ));
271            }
272        };
273        if current.as_slice() != &*change.original {
274            cleanup(replacements.as_slice());
275            return Err(install_failure(
276                "a selected file changed while cli-git fix ran, so no file was changed. Run cli-git fix again.",
277            ));
278        }
279        let parent: PathBuf = match destination.parent() {
280            Some(found) => found.to_path_buf(),
281            None => root.to_path_buf(),
282        };
283        let prefix: String = format!("{INSTALL_SIBLING_PREFIX}{}", unique_suffix());
284        let replacement: Replacement = Replacement {
285            prepared: parent.join(format!("{prefix}.new")),
286            backup: parent.join(format!("{prefix}.old")),
287            destination,
288        };
289        let written: std::io::Result<()> = match std::fs::copy(
290            replacement.destination.as_path(),
291            replacement.backup.as_path(),
292        ) {
293            Ok(_) => write_new(
294                replacement.prepared.as_path(),
295                &change.replacement,
296                change.mode,
297            ),
298            Err(error) => Err(error),
299        };
300        // Recorded before the failure check, so a failure still removes what was written.
301        replacements.push(replacement);
302        if let Err(error) = written {
303            cleanup(replacements.as_slice());
304            return Err(install_failure(
305                format!("writing a corrected file beside its original failed ({error}), so no file was changed.").as_str(),
306            ));
307        }
308    }
309    return Ok(replacements);
310}
311
312/// What:
313///  Rename every prepared file over its destination,
314///  then prove the real index still
315///       holds `index_before`.
316///  `&Option<Vec<u8>>` borrows the bytes read before,
317///  or absence.
318/// Why:
319///   The fix changes the worktree only.
320///  A rename that fails,
321///  an index that changed,
322///       or an index that cannot be read restores every file already replaced.
323///
324/// In TS you'd write (pseudocode):
325/// ```ts
326/// async function installPrepared(replacements, realIndexPath, indexBefore): Promise<void>;
327/// ```
328pub fn install_prepared(
329    replacements: &[Replacement],
330    real_index: &Path,
331    index_before: &Option<Vec<u8>>,
332) -> Result<(), String> {
333    let mut installed: usize = 0;
334    let mut failure: Option<String> = None;
335    for replacement in replacements {
336        if let Err(error) = std::fs::rename(
337            replacement.prepared.as_path(),
338            replacement.destination.as_path(),
339        ) {
340            failure = Some(format!("replacing a corrected file failed ({error})"));
341            break;
342        }
343        installed += 1;
344    }
345    if failure.is_none() {
346        failure = match read_index(real_index) {
347            Ok(after) if after == *index_before => None,
348            Ok(_) => Some(String::from("the index changed while cli-git fix ran")),
349            Err(error) => Some(format!("reading the index again failed ({error})")),
350        };
351    }
352    let Some(cause) = failure else {
353        cleanup(replacements);
354        return Ok(());
355    };
356    // `..installed` is the files already replaced, which the rollback restores.
357    let restored: Result<(), String> = rollback(&replacements[..installed]);
358    match restored {
359        Ok(()) => {
360            cleanup(replacements);
361            return Err(install_failure(
362                format!("{cause}, so every corrected file was restored. Run cli-git fix again.")
363                    .as_str(),
364            ));
365        }
366        Err(restore_failure) => {
367            // Prepared files are removed; backups stay, because one of them is needed.
368            for replacement in replacements {
369                let _ = std::fs::remove_file(replacement.prepared.as_path());
370            }
371            return Err(install_failure(
372                format!("{cause}, and {restore_failure}.").as_str(),
373            ));
374        }
375    }
376}
377
378/// What:
379///  Install every change,
380///  or none:
381///  read the real index,
382///  prepare,
383///  then rename and
384///       verify.
385///  `Result<(), String>` is success or the failure message to report.
386/// Why:
387///   The whole protocol of the installed wrapper's `installDirectFix`,
388///  apart from the
389///       landing lock,
390///  which the native wrapper does not take.
391///
392/// In TS you'd write (pseudocode):
393/// ```ts
394/// async function installCorrections(root, realIndexPath, changes): Promise<void>;
395/// ```
396pub fn install_corrections(
397    root: &Path,
398    real_index: &Path,
399    changes: &[InstallChange],
400) -> Result<(), String> {
401    let index_before: Option<Vec<u8>> = match read_index(real_index) {
402        Ok(found) => found,
403        Err(error) => {
404            return Err(install_failure(
405                format!("reading the index failed ({error}), so no file was changed.").as_str(),
406            ));
407        }
408    };
409    let replacements: Vec<Replacement> = prepare_replacements(root, changes)?;
410    return install_prepared(replacements.as_slice(), real_index, &index_before);
411}
412
413/// Installation,
414///  rollback and cleanup controls stay out of the release executable.
415#[cfg(test)]
416#[path = "direct_fix_install_tests.rs"]
417mod tests;