Skip to main content

borg/install/
change.rs

1//! One config file as it is and as it would be ([`Change`]), its diff, and writing it: a timestamped
2//! backup first, then an atomic replace that keeps the file's permissions. `borg install` (`install.rs`,
3//! `install/skill.rs`) and `borg mesh join` write every file through here.
4
5use std::path::{Path, PathBuf};
6
7/// One file as it is and as it would be (`None` = absent).
8#[derive(Debug, Clone, PartialEq)]
9pub struct Change {
10    pub path: PathBuf,
11    pub before: Option<String>,
12    pub after: Option<String>,
13    /// Settings files are often symlinks into a dotfiles repo: write through them. An adapter file
14    /// symlinked into a borg checkout (README's `ln -s`) is replaced instead, never written through.
15    pub through_links: bool,
16    /// Whether `apply` keeps a backup of `before`. False only where borg can recreate it exactly: a
17    /// skill file removed because it was this borg's own (`install/skill.rs`), whose backup would keep
18    /// the skill's directory from ever being removed.
19    pub backup: bool,
20}
21
22/// What a plan does: wire borg in, or take it out.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Action {
25    Install,
26    Uninstall,
27}
28
29/// A file's text; `Ok(None)` only when it does not exist. Unreadable (permissions, not UTF-8) is an
30/// error, never "absent": treating it as absent would replace the file without a backup.
31pub(crate) fn read(p: &Path) -> std::io::Result<Option<String>> {
32    match std::fs::read_to_string(p) {
33        Ok(t) => Ok(Some(t)),
34        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
35        Err(e) => Err(e),
36    }
37}
38
39/// Mode for files borg creates where none existed: settings.json often holds secrets in `env`.
40/// Protocol: owner read and write only, as for a credentials file.
41#[cfg(unix)]
42const NEW_FILE_MODE: u32 = 0o600;
43
44/// Creates `p` (never an existing file) with `text` and permissions `mode` (unix; ignored elsewhere).
45/// The mode is set at creation, so the file is never readable by more people than intended, and
46/// set again afterwards, because creation is narrowed by the umask.
47fn create_with_mode(p: &Path, text: &str, mode: Option<u32>) -> std::io::Result<()> {
48    use std::io::Write;
49    let mut o = std::fs::OpenOptions::new();
50    o.write(true).create_new(true);
51    #[cfg(unix)]
52    if let Some(m) = mode {
53        use std::os::unix::fs::OpenOptionsExt;
54        o.mode(m);
55    }
56    let mut f = o.open(p)?;
57    #[cfg(unix)]
58    if let Some(m) = mode {
59        use std::os::unix::fs::PermissionsExt;
60        f.set_permissions(std::fs::Permissions::from_mode(m))?;
61    }
62    #[cfg(not(unix))]
63    let _ = mode;
64    f.write_all(text.as_bytes())?;
65    f.sync_all()
66}
67
68/// `<file>.borg-backup-<stamp>`, or `-1`, `-2`, … after it: an existing backup is never overwritten
69/// (an install and an uninstall in the same second would otherwise lose the original).
70fn write_backup(path: &Path, name: &str, stamp: &str, text: &str, mode: Option<u32>) -> std::io::Result<PathBuf> {
71    for n in 0u32.. {
72        let suffix = if n == 0 { String::new() } else { format!("-{n}") };
73        let p = path.with_file_name(format!("{name}.borg-backup-{stamp}{suffix}"));
74        match create_with_mode(&p, text, mode) {
75            Ok(()) => return Ok(p),
76            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
77            Err(e) => return Err(e),
78        }
79    }
80    unreachable!("u32 backups")
81}
82
83#[cfg(unix)]
84fn mode_of(p: &Path) -> Option<u32> {
85    use std::os::unix::fs::PermissionsExt;
86    std::fs::metadata(p).ok().map(|m| m.permissions().mode() & 0o7777)
87}
88
89#[cfg(not(unix))]
90fn mode_of(_: &Path) -> Option<u32> {
91    None
92}
93
94/// Writes one change: a backup of the existing file first, then an atomic replace. Returns the backup.
95/// The replacement and the backup keep the original's permissions (a 0600 file stays 0600); a file
96/// that did not exist is created 0600.
97///
98/// # Errors
99/// The backup or the write failed; the file is then as it was.
100pub fn apply(c: &Change, stamp: &str) -> std::io::Result<Option<PathBuf>> {
101    let name = c.path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
102    let mode = mode_of(&c.path); // follows a symlink: the mode of the file actually read
103    let backup = match &c.before {
104        Some(b) if c.backup => Some(write_backup(&c.path, &name, stamp, b, mode)?),
105        _ => None,
106    };
107    match &c.after {
108        None => std::fs::remove_file(&c.path)?,
109        Some(text) => replace(c, &name, text, mode)?,
110    }
111    Ok(backup)
112}
113
114/// `c.path` now holds `text`, written to a temporary file beside it and renamed over it: through a
115/// symlink where `c.through_links`, else replacing the symlink itself.
116fn replace(c: &Change, name: &str, text: &str, mode: Option<u32>) -> std::io::Result<()> {
117    let is_link = std::fs::symlink_metadata(&c.path).is_ok_and(|m| m.file_type().is_symlink());
118    let target = if is_link && c.through_links { std::fs::canonicalize(&c.path)? } else { c.path.clone() };
119    if let Some(d) = target.parent() {
120        std::fs::create_dir_all(d)?;
121    }
122    #[cfg(unix)]
123    let mode = mode.or(Some(NEW_FILE_MODE));
124    let tmp = target.with_file_name(format!(".{name}.borg-tmp"));
125    let _ = std::fs::remove_file(&tmp); // a leftover of an interrupted run, with whatever mode it had
126    create_with_mode(&tmp, text, mode)?;
127    std::fs::rename(&tmp, &target) // replaces a symlink itself when not writing through it
128}
129
130/// A unified diff of two texts, 3 lines of context.
131#[must_use]
132pub fn diff(path: &Path, before: Option<&str>, after: Option<&str>) -> String {
133    let a: Vec<&str> = before.map(|t| t.lines().collect()).unwrap_or_default();
134    let b: Vec<&str> = after.map(|t| t.lines().collect()).unwrap_or_default();
135    let label = |t: Option<&str>| if t.is_some() { path.display().to_string() } else { "/dev/null".into() };
136    format!("--- {}\n+++ {}\n{}", label(before), label(after), hunks(&ops(&a, &b)))
137}
138
139/// The edit from `a` to `b` as ' ', '-' and '+' lines: the longest common subsequence, walked.
140fn ops<'t>(a: &[&'t str], b: &[&'t str]) -> Vec<(char, &'t str)> {
141    let lcs = lcs(a, b);
142    let (mut i, mut j, mut ops) = (0, 0, Vec::new());
143    while i < a.len() || j < b.len() {
144        if i < a.len() && j < b.len() && a[i] == b[j] {
145            ops.push((' ', a[i]));
146            (i, j) = (i + 1, j + 1);
147        } else if j < b.len() && (i == a.len() || lcs[i][j + 1] >= lcs[i + 1][j]) {
148            ops.push(('+', b[j]));
149            j += 1;
150        } else {
151            ops.push(('-', a[i]));
152            i += 1;
153        }
154    }
155    ops
156}
157
158/// `lcs[i][j]`: the length of the longest common subsequence of `a[i..]` and `b[j..]`.
159fn lcs(a: &[&str], b: &[&str]) -> Vec<Vec<u32>> {
160    let (n, m) = (a.len(), b.len());
161    let mut lcs = vec![vec![0u32; m + 1]; n + 1];
162    for i in (0..n).rev() {
163        for j in (0..m).rev() {
164            lcs[i][j] = if a[i] == b[j] { lcs[i + 1][j + 1] + 1 } else { lcs[i + 1][j].max(lcs[i][j + 1]) };
165        }
166    }
167    lcs
168}
169
170/// The lines of `ops` within 3 of a change, each run of them after an `@@` line.
171fn hunks(ops: &[(char, &str)]) -> String {
172    let near = |k: usize| ops[k.saturating_sub(3)..(k + 4).min(ops.len())].iter().any(|(o, _)| *o != ' ');
173    let mut out = String::new();
174    let mut gap = true;
175    for (k, (o, line)) in ops.iter().enumerate() {
176        if near(k) {
177            if gap {
178                out.push_str("@@\n");
179            }
180            out.push(*o);
181            out.push_str(line);
182            out.push('\n');
183            gap = false;
184        } else {
185            gap = true;
186        }
187    }
188    out
189}