Skip to main content

text_mirror/
walk.rs

1//! Deterministic traversal of a division root.
2
3use std::path::{Path, PathBuf};
4
5use walkdir::WalkDir;
6
7use crate::{Error, Result};
8
9/// Traversal options for a division root.
10///
11/// Two exclusion lists, both matched against a bare entry name at any
12/// depth: [`ignore_names`](Self::ignore_names) by exact equality and
13/// [`ignore_suffixes`](Self::ignore_suffixes) by a plain name suffix.
14/// The suffix list is a small fixed set, not a glob or regex engine, so
15/// the policy stays explicit and cheap to read. A skipped directory
16/// hides its whole subtree under either list.
17///
18/// [`Default`] ships the noise policy, so the excludes are on at every
19/// call site that builds options this way rather than opt-in.
20#[derive(Debug, Clone)]
21pub struct WalkOptions {
22    /// Exact file and directory names to skip at any depth.
23    pub ignore_names: Vec<String>,
24    /// Name suffixes to skip at any depth. An entry is skipped when its
25    /// bare name ends with any listed suffix.
26    pub ignore_suffixes: Vec<String>,
27}
28
29impl Default for WalkOptions {
30    /// The shipped noise policy: the macOS `.DS_Store` sidecar by exact
31    /// name, and the SQLite write-ahead sidecars plus generic temp
32    /// files by suffix.
33    ///
34    /// Data-bearing dotfiles are deliberately absent: `.env`, ssh keys,
35    /// `.gitconfig`, and `.config`-style files are primary
36    /// classification targets, so nothing here excludes them.
37    ///
38    /// The crate's own mirror work directories need no exclusion here.
39    /// A container expands its members under a `<source>.d/` namespace
40    /// in the mirror tree, and `check_layout` (pipeline.rs) forces the
41    /// mirror root and the division root to be mutually disjoint, so
42    /// those directories never fall under a source walk. Their absence
43    /// from this list is a decision, not an oversight, and the
44    /// `walks_config_dot_d_directories` test blocks any bare `.d`
45    /// suffix from being added back.
46    fn default() -> Self {
47        WalkOptions {
48            ignore_names: vec![".DS_Store".to_string()],
49            ignore_suffixes: vec!["-wal".to_string(), "-shm".to_string(), ".tmp".to_string()],
50        }
51    }
52}
53
54/// How the walk classified a directory entry.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum EntryKind {
57    /// A regular file.
58    File,
59    /// A symlink. Symlinks are never followed.
60    Symlink,
61    /// Anything else, such as a FIFO or a socket.
62    Other,
63}
64
65/// One entry found by the walk.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct WalkedEntry {
68    /// Path relative to the walked root.
69    pub path: PathBuf,
70    /// The entry kind.
71    pub kind: EntryKind,
72}
73
74/// Walks `root` and returns every non-directory entry.
75///
76/// Entries are sorted by file name at each directory, so two walks of
77/// the same tree return the same list in the same order. Symlinks are
78/// never followed, and a root that is itself a symlink is rejected
79/// before any traversal. Entries whose name matches
80/// [`WalkOptions::ignore_names`] exactly or ends with any
81/// [`WalkOptions::ignore_suffixes`] entry are skipped, and a skipped
82/// directory hides its whole subtree.
83pub fn walk_division(root: &Path, options: &WalkOptions) -> Result<Vec<WalkedEntry>> {
84    let root_meta = std::fs::symlink_metadata(root).map_err(|e| Error::io("walk", root, e))?;
85    if root_meta.file_type().is_symlink() {
86        return Err(Error::Layout {
87            message: format!("division root {} is a symlink", root.display()),
88        });
89    }
90    if !root_meta.is_dir() {
91        return Err(Error::io(
92            "walk",
93            root,
94            std::io::Error::new(std::io::ErrorKind::NotFound, "not a directory"),
95        ));
96    }
97    let mut entries = Vec::new();
98    let walker = WalkDir::new(root)
99        .follow_links(false)
100        .follow_root_links(false)
101        .sort_by_file_name()
102        .into_iter()
103        .filter_entry(|entry| {
104            if entry.depth() == 0 {
105                return true;
106            }
107            let name = entry.file_name().to_string_lossy();
108            if options.ignore_names.iter().any(|ignored| ignored == &name) {
109                return false;
110            }
111            !options
112                .ignore_suffixes
113                .iter()
114                .any(|suffix| name.ends_with(suffix.as_str()))
115        });
116    for entry in walker {
117        let entry = entry.map_err(|e| {
118            let path = e.path().unwrap_or(root).to_path_buf();
119            let source = e
120                .into_io_error()
121                .unwrap_or_else(|| std::io::Error::other("walk failed"));
122            Error::Io {
123                op: "walk",
124                path,
125                source,
126            }
127        })?;
128        let file_type = entry.file_type();
129        let kind = if file_type.is_file() {
130            EntryKind::File
131        } else if file_type.is_symlink() {
132            EntryKind::Symlink
133        } else if file_type.is_dir() {
134            continue;
135        } else {
136            EntryKind::Other
137        };
138        let relative = entry
139            .path()
140            .strip_prefix(root)
141            .expect("walk entries live under the root")
142            .to_path_buf();
143        entries.push(WalkedEntry {
144            path: relative,
145            kind,
146        });
147    }
148    Ok(entries)
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use std::fs;
155
156    fn touch(path: &Path) {
157        fs::create_dir_all(path.parent().unwrap()).unwrap();
158        fs::write(path, b"x").unwrap();
159    }
160
161    fn paths(entries: &[WalkedEntry]) -> Vec<&Path> {
162        entries.iter().map(|e| e.path.as_path()).collect()
163    }
164
165    #[test]
166    fn walk_is_sorted_and_repeatable() {
167        let dir = tempfile::tempdir().unwrap();
168        let root = dir.path();
169        touch(&root.join("zeta.txt"));
170        touch(&root.join("alpha.txt"));
171        touch(&root.join("mid/inner.txt"));
172        touch(&root.join("mid/another.txt"));
173
174        let options = WalkOptions::default();
175        let first = walk_division(root, &options).unwrap();
176        let second = walk_division(root, &options).unwrap();
177        let expected = [
178            Path::new("alpha.txt"),
179            Path::new("mid/another.txt"),
180            Path::new("mid/inner.txt"),
181            Path::new("zeta.txt"),
182        ];
183        assert_eq!(paths(&first), expected);
184        assert_eq!(first, second);
185        assert!(first.iter().all(|e| e.kind == EntryKind::File));
186    }
187
188    #[test]
189    fn walk_skips_ignored_names() {
190        let dir = tempfile::tempdir().unwrap();
191        let root = dir.path();
192        touch(&root.join("keep.txt"));
193        touch(&root.join(".cache/skip.txt"));
194        touch(&root.join("thumbs.db"));
195
196        let options = WalkOptions {
197            ignore_names: vec![".cache".to_string(), "thumbs.db".to_string()],
198            ignore_suffixes: Vec::new(),
199        };
200        let entries = walk_division(root, &options).unwrap();
201        assert_eq!(paths(&entries), [Path::new("keep.txt")]);
202    }
203
204    // The default noise policy is locked below. These tests exercise
205    // `WalkOptions::default()`, the exact value built at the real call
206    // sites (main.rs scan and run), so they assert the shipped policy,
207    // not a test-only construction.
208
209    #[test]
210    fn walks_sensitive_dotfiles_by_default() {
211        let dir = tempfile::tempdir().unwrap();
212        let root = dir.path();
213        touch(&root.join(".env"));
214        touch(&root.join(".ssh/id_rsa"));
215        touch(&root.join(".config"));
216        touch(&root.join(".gitconfig"));
217
218        let entries = walk_division(root, &WalkOptions::default()).unwrap();
219        let found = paths(&entries);
220        for expected in [
221            Path::new(".config"),
222            Path::new(".env"),
223            Path::new(".gitconfig"),
224            Path::new(".ssh/id_rsa"),
225        ] {
226            assert!(found.contains(&expected), "{expected:?} must be walked");
227        }
228    }
229
230    #[test]
231    fn walks_config_dot_d_directories() {
232        // A `.d` config directory is a first-class source, not noise.
233        // This locks the config crawl and blocks anyone reintroducing a
234        // bare `.d` suffix exclude.
235        let dir = tempfile::tempdir().unwrap();
236        let root = dir.path();
237        touch(&root.join("conf.d/app.conf"));
238        touch(&root.join("init.d/service"));
239
240        let entries = walk_division(root, &WalkOptions::default()).unwrap();
241        let found = paths(&entries);
242        assert!(found.contains(&Path::new("conf.d/app.conf")));
243        assert!(found.contains(&Path::new("init.d/service")));
244    }
245
246    #[test]
247    fn excludes_the_default_noise_set() {
248        let dir = tempfile::tempdir().unwrap();
249        let root = dir.path();
250        touch(&root.join("keep.txt"));
251        touch(&root.join(".DS_Store"));
252        touch(&root.join("foo-wal"));
253        touch(&root.join("foo-shm"));
254        touch(&root.join("bar.tmp"));
255
256        let entries = walk_division(root, &WalkOptions::default()).unwrap();
257        assert_eq!(paths(&entries), [Path::new("keep.txt")]);
258    }
259
260    #[test]
261    fn default_options_carry_the_noise_policy() {
262        // The policy is on by default: the exact set below is what the
263        // real call sites apply. Nothing more is excluded.
264        let options = WalkOptions::default();
265        assert_eq!(options.ignore_names, vec![".DS_Store".to_string()]);
266        assert_eq!(
267            options.ignore_suffixes,
268            vec!["-wal".to_string(), "-shm".to_string(), ".tmp".to_string()]
269        );
270    }
271
272    #[cfg(unix)]
273    #[test]
274    fn walk_returns_symlinks_as_entries_without_following() {
275        let dir = tempfile::tempdir().unwrap();
276        let root = dir.path();
277        touch(&root.join("outside/secret.txt"));
278
279        let scoped = root.join("division");
280        fs::create_dir_all(&scoped).unwrap();
281        touch(&scoped.join("real.txt"));
282        std::os::unix::fs::symlink(root.join("outside/secret.txt"), scoped.join("link.txt"))
283            .unwrap();
284        std::os::unix::fs::symlink(root.join("outside"), scoped.join("linked-dir")).unwrap();
285
286        let entries = walk_division(&scoped, &WalkOptions::default()).unwrap();
287        assert_eq!(
288            entries,
289            vec![
290                WalkedEntry {
291                    path: PathBuf::from("link.txt"),
292                    kind: EntryKind::Symlink,
293                },
294                WalkedEntry {
295                    path: PathBuf::from("linked-dir"),
296                    kind: EntryKind::Symlink,
297                },
298                WalkedEntry {
299                    path: PathBuf::from("real.txt"),
300                    kind: EntryKind::File,
301                },
302            ]
303        );
304    }
305
306    #[cfg(unix)]
307    #[test]
308    fn walk_rejects_a_symlinked_root() {
309        let dir = tempfile::tempdir().unwrap();
310        let root = dir.path();
311        touch(&root.join("outside/secret.txt"));
312        let link = root.join("root-link");
313        std::os::unix::fs::symlink(root.join("outside"), &link).unwrap();
314
315        let err = walk_division(&link, &WalkOptions::default()).unwrap_err();
316        assert!(err.to_string().contains("symlink"));
317    }
318}