Skip to main content

dodot_lib/
equivalence.rs

1//! Equivalence detection for "would deploying make any content change?"
2//!
3//! When dodot's deploy would result in the same content reaching the
4//! user's target path, the existing file/symlink can be safely replaced
5//! with dodot's standard chain without prompting. This is the
6//! "no-content-change is no-conflict" principle from issue #44.
7//!
8//! Two cases qualify as equivalent:
9//!
10//! - **Direct (single-hop) symlink** whose target is exactly `source`.
11//!   `up` will replace `user_path → source` with `user_path → data_link
12//!   → source`. Same realpath, same content reaches the same path.
13//!
14//! - **Regular file** whose byte content matches `source` exactly.
15//!   `up` will replace the file with a symlink to the data_link. The
16//!   content the user reads stays bit-identical.
17//!
18//! Multi-hop symlink chains are deliberately *not* treated as
19//! equivalent even if their realpath matches `source`. The chain
20//! probably exists for a reason and we shouldn't second-guess it.
21
22use std::path::{Path, PathBuf};
23
24use crate::fs::Fs;
25
26/// Resolve a single symlink hop's raw `readlink()` target into an
27/// absolute path, in the same way the kernel does on traversal:
28///
29/// - Absolute targets are returned as-is.
30/// - Relative targets are joined onto `link_path.parent()`. (No
31///   canonicalization, no following further hops — that's deliberately
32///   single-hop only.)
33///
34/// Used by equivalence detection so that `~/.vimrc → ../dotfiles/vim/vimrc`
35/// is recognised as a direct link to `<dotfiles>/vim/vimrc` instead of
36/// being mis-classified as "points elsewhere".
37pub fn resolve_symlink_target(link_path: &Path, raw_target: &Path) -> PathBuf {
38    if raw_target.is_absolute() {
39        raw_target.to_path_buf()
40    } else {
41        link_path
42            .parent()
43            .unwrap_or_else(|| Path::new(""))
44            .join(raw_target)
45    }
46}
47
48/// Collapse `.` and `..` components lexically, without touching the
49/// filesystem. Needed for prefix comparisons: `starts_with` is purely
50/// lexical, so a path like `/home/u/.config/../dotfiles/warp` does not
51/// start with `/home/u/dotfiles` even though they resolve to the same
52/// place.
53///
54/// Unlike `std::fs::canonicalize`, this does NOT follow symlinks —
55/// callers that want lexical normalization of a symlink target joined
56/// against its parent path get the right answer without an extra round
57/// through the OS.
58pub fn normalize_path(path: &Path) -> PathBuf {
59    use std::path::Component;
60    let mut result = PathBuf::new();
61    for component in path.components() {
62        match component {
63            Component::CurDir => {}
64            Component::ParentDir => {
65                result.pop();
66            }
67            other => result.push(other),
68        }
69    }
70    result
71}
72
73/// Whether the existing thing at `user_path` is content-equivalent to
74/// `source` — meaning `dodot up` would produce the same content
75/// reaching the same path, so it's safe to replace without `--force`.
76///
77/// See module-level docs for the exact equivalence rules.
78pub fn is_equivalent(user_path: &Path, source: &Path, fs: &dyn Fs) -> bool {
79    if fs.is_symlink(user_path) {
80        // Single-hop direct symlink to source. Resolve relative targets
81        // against the symlink's parent so e.g. `~/.vimrc -> ../foo/vimrc`
82        // is recognised. Multi-hop chains and links pointing elsewhere
83        // fall through to false.
84        match fs.readlink(user_path) {
85            Ok(target) => resolve_symlink_target(user_path, &target) == source,
86            Err(_) => false,
87        }
88    } else if fs.exists(user_path) && !fs.is_dir(user_path) {
89        // Regular file: byte equality with source.
90        match (fs.read_file(user_path), fs.read_file(source)) {
91            (Ok(a), Ok(b)) => a == b,
92            _ => false,
93        }
94    } else {
95        // Absent, directory, or unreadable.
96        false
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::testing::TempEnvironment;
104
105    #[test]
106    fn relative_direct_symlink_to_source_is_equivalent() {
107        // Regression for PR #47 review: `~/.vimrc -> ../dotfiles/vim/vimrc`
108        // (relative target) must resolve to the same absolute source path,
109        // not be mis-classified as "points elsewhere".
110        //
111        // TempEnvironment lays out dotfiles_root inside home (as
112        // `<home>/dotfiles`), so a symlink at `<home>/.vimrc` reaches
113        // the source via the relative path `dotfiles/vim/vimrc`.
114        let env = TempEnvironment::builder()
115            .pack("vim")
116            .file("vimrc", "set nocompatible")
117            .done()
118            .build();
119
120        let source = env.dotfiles_root.join("vim/vimrc");
121        let user_path = env.home.join(".vimrc");
122        let relative_target = std::path::PathBuf::from("dotfiles/vim/vimrc");
123        env.fs.symlink(&relative_target, &user_path).unwrap();
124
125        // Sanity check the layout assumption that makes the relative path
126        // resolve correctly (test would silently pass for the wrong reason
127        // otherwise).
128        assert_eq!(
129            resolve_symlink_target(&user_path, &relative_target),
130            source,
131            "test layout assumption broke: relative target doesn't resolve to source"
132        );
133
134        assert!(
135            is_equivalent(&user_path, &source, env.fs.as_ref()),
136            "relative symlink to source should be equivalent (resolved against link's parent)"
137        );
138    }
139
140    #[test]
141    fn direct_symlink_to_source_is_equivalent() {
142        let env = TempEnvironment::builder()
143            .pack("vim")
144            .file("vimrc", "set nocompatible")
145            .done()
146            .build();
147
148        let source = env.dotfiles_root.join("vim/vimrc");
149        let user_path = env.home.join(".vimrc");
150        env.fs.symlink(&source, &user_path).unwrap();
151
152        assert!(is_equivalent(&user_path, &source, env.fs.as_ref()));
153    }
154
155    #[test]
156    fn symlink_pointing_elsewhere_is_not_equivalent() {
157        let env = TempEnvironment::builder()
158            .pack("vim")
159            .file("vimrc", "set nocompatible")
160            .done()
161            .build();
162
163        let source = env.dotfiles_root.join("vim/vimrc");
164        let user_path = env.home.join(".vimrc");
165        env.fs
166            .symlink(std::path::Path::new("/tmp/somewhere-else"), &user_path)
167            .unwrap();
168
169        assert!(!is_equivalent(&user_path, &source, env.fs.as_ref()));
170    }
171
172    #[test]
173    fn multi_hop_symlink_to_source_is_not_equivalent() {
174        // Even though the realpath matches, the chain exists for a
175        // reason — leave it alone.
176        let env = TempEnvironment::builder()
177            .pack("vim")
178            .file("vimrc", "set nocompatible")
179            .done()
180            .build();
181
182        let source = env.dotfiles_root.join("vim/vimrc");
183        let intermediate = env.home.join(".vimrc.intermediate");
184        let user_path = env.home.join(".vimrc");
185        env.fs.symlink(&source, &intermediate).unwrap();
186        env.fs.symlink(&intermediate, &user_path).unwrap();
187
188        assert!(!is_equivalent(&user_path, &source, env.fs.as_ref()));
189    }
190
191    #[test]
192    fn regular_file_with_identical_content_is_equivalent() {
193        let env = TempEnvironment::builder()
194            .pack("git")
195            .file("gitconfig", "[user]\n  name = test")
196            .done()
197            .home_file(".gitconfig", "[user]\n  name = test")
198            .build();
199
200        let source = env.dotfiles_root.join("git/gitconfig");
201        let user_path = env.home.join(".gitconfig");
202
203        assert!(is_equivalent(&user_path, &source, env.fs.as_ref()));
204    }
205
206    #[test]
207    fn regular_file_with_different_content_is_not_equivalent() {
208        let env = TempEnvironment::builder()
209            .pack("git")
210            .file("gitconfig", "[user]\n  name = new")
211            .done()
212            .home_file(".gitconfig", "[user]\n  name = old")
213            .build();
214
215        let source = env.dotfiles_root.join("git/gitconfig");
216        let user_path = env.home.join(".gitconfig");
217
218        assert!(!is_equivalent(&user_path, &source, env.fs.as_ref()));
219    }
220
221    #[test]
222    fn absent_user_path_is_not_equivalent() {
223        let env = TempEnvironment::builder()
224            .pack("vim")
225            .file("vimrc", "x")
226            .done()
227            .build();
228
229        let source = env.dotfiles_root.join("vim/vimrc");
230        let user_path = env.home.join(".vimrc");
231        // No file created at user_path.
232
233        assert!(!is_equivalent(&user_path, &source, env.fs.as_ref()));
234    }
235
236    #[test]
237    fn directory_is_not_equivalent() {
238        let env = TempEnvironment::builder()
239            .pack("vim")
240            .file("vimrc", "x")
241            .done()
242            .build();
243
244        let source = env.dotfiles_root.join("vim/vimrc");
245        let user_path = env.home.join(".vimrc");
246        env.fs.mkdir_all(&user_path).unwrap();
247
248        assert!(!is_equivalent(&user_path, &source, env.fs.as_ref()));
249    }
250
251    #[test]
252    fn broken_symlink_is_not_equivalent() {
253        let env = TempEnvironment::builder()
254            .pack("vim")
255            .file("vimrc", "x")
256            .done()
257            .build();
258
259        let source = env.dotfiles_root.join("vim/vimrc");
260        let user_path = env.home.join(".vimrc");
261        env.fs
262            .symlink(std::path::Path::new("/does/not/exist"), &user_path)
263            .unwrap();
264
265        assert!(!is_equivalent(&user_path, &source, env.fs.as_ref()));
266    }
267}