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#[derive(Debug, Clone, Default)]
11pub struct WalkOptions {
12    /// Exact file and directory names to skip at any depth.
13    pub ignore_names: Vec<String>,
14}
15
16/// How the walk classified a directory entry.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum EntryKind {
19    /// A regular file.
20    File,
21    /// A symlink. Symlinks are never followed.
22    Symlink,
23    /// Anything else, such as a FIFO or a socket.
24    Other,
25}
26
27/// One entry found by the walk.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct WalkedEntry {
30    /// Path relative to the walked root.
31    pub path: PathBuf,
32    /// The entry kind.
33    pub kind: EntryKind,
34}
35
36/// Walks `root` and returns every non-directory entry.
37///
38/// Entries are sorted by file name at each directory, so two walks of
39/// the same tree return the same list in the same order. Symlinks are
40/// never followed, and a root that is itself a symlink is rejected
41/// before any traversal. Names listed in
42/// [`WalkOptions::ignore_names`] are skipped, and a skipped directory
43/// hides its whole subtree.
44pub fn walk_division(root: &Path, options: &WalkOptions) -> Result<Vec<WalkedEntry>> {
45    let root_meta = std::fs::symlink_metadata(root).map_err(|e| Error::io("walk", root, e))?;
46    if root_meta.file_type().is_symlink() {
47        return Err(Error::Layout {
48            message: format!("division root {} is a symlink", root.display()),
49        });
50    }
51    if !root_meta.is_dir() {
52        return Err(Error::io(
53            "walk",
54            root,
55            std::io::Error::new(std::io::ErrorKind::NotFound, "not a directory"),
56        ));
57    }
58    let mut entries = Vec::new();
59    let walker = WalkDir::new(root)
60        .follow_links(false)
61        .follow_root_links(false)
62        .sort_by_file_name()
63        .into_iter()
64        .filter_entry(|entry| {
65            if entry.depth() == 0 {
66                return true;
67            }
68            let name = entry.file_name().to_string_lossy();
69            !options.ignore_names.iter().any(|ignored| ignored == &name)
70        });
71    for entry in walker {
72        let entry = entry.map_err(|e| {
73            let path = e.path().unwrap_or(root).to_path_buf();
74            let source = e
75                .into_io_error()
76                .unwrap_or_else(|| std::io::Error::other("walk failed"));
77            Error::Io {
78                op: "walk",
79                path,
80                source,
81            }
82        })?;
83        let file_type = entry.file_type();
84        let kind = if file_type.is_file() {
85            EntryKind::File
86        } else if file_type.is_symlink() {
87            EntryKind::Symlink
88        } else if file_type.is_dir() {
89            continue;
90        } else {
91            EntryKind::Other
92        };
93        let relative = entry
94            .path()
95            .strip_prefix(root)
96            .expect("walk entries live under the root")
97            .to_path_buf();
98        entries.push(WalkedEntry {
99            path: relative,
100            kind,
101        });
102    }
103    Ok(entries)
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use std::fs;
110
111    fn touch(path: &Path) {
112        fs::create_dir_all(path.parent().unwrap()).unwrap();
113        fs::write(path, b"x").unwrap();
114    }
115
116    fn paths(entries: &[WalkedEntry]) -> Vec<&Path> {
117        entries.iter().map(|e| e.path.as_path()).collect()
118    }
119
120    #[test]
121    fn walk_is_sorted_and_repeatable() {
122        let dir = tempfile::tempdir().unwrap();
123        let root = dir.path();
124        touch(&root.join("zeta.txt"));
125        touch(&root.join("alpha.txt"));
126        touch(&root.join("mid/inner.txt"));
127        touch(&root.join("mid/another.txt"));
128
129        let options = WalkOptions::default();
130        let first = walk_division(root, &options).unwrap();
131        let second = walk_division(root, &options).unwrap();
132        let expected = [
133            Path::new("alpha.txt"),
134            Path::new("mid/another.txt"),
135            Path::new("mid/inner.txt"),
136            Path::new("zeta.txt"),
137        ];
138        assert_eq!(paths(&first), expected);
139        assert_eq!(first, second);
140        assert!(first.iter().all(|e| e.kind == EntryKind::File));
141    }
142
143    #[test]
144    fn walk_skips_ignored_names() {
145        let dir = tempfile::tempdir().unwrap();
146        let root = dir.path();
147        touch(&root.join("keep.txt"));
148        touch(&root.join(".cache/skip.txt"));
149        touch(&root.join("thumbs.db"));
150
151        let options = WalkOptions {
152            ignore_names: vec![".cache".to_string(), "thumbs.db".to_string()],
153        };
154        let entries = walk_division(root, &options).unwrap();
155        assert_eq!(paths(&entries), [Path::new("keep.txt")]);
156    }
157
158    #[cfg(unix)]
159    #[test]
160    fn walk_returns_symlinks_as_entries_without_following() {
161        let dir = tempfile::tempdir().unwrap();
162        let root = dir.path();
163        touch(&root.join("outside/secret.txt"));
164
165        let scoped = root.join("division");
166        fs::create_dir_all(&scoped).unwrap();
167        touch(&scoped.join("real.txt"));
168        std::os::unix::fs::symlink(root.join("outside/secret.txt"), scoped.join("link.txt"))
169            .unwrap();
170        std::os::unix::fs::symlink(root.join("outside"), scoped.join("linked-dir")).unwrap();
171
172        let entries = walk_division(&scoped, &WalkOptions::default()).unwrap();
173        assert_eq!(
174            entries,
175            vec![
176                WalkedEntry {
177                    path: PathBuf::from("link.txt"),
178                    kind: EntryKind::Symlink,
179                },
180                WalkedEntry {
181                    path: PathBuf::from("linked-dir"),
182                    kind: EntryKind::Symlink,
183                },
184                WalkedEntry {
185                    path: PathBuf::from("real.txt"),
186                    kind: EntryKind::File,
187                },
188            ]
189        );
190    }
191
192    #[cfg(unix)]
193    #[test]
194    fn walk_rejects_a_symlinked_root() {
195        let dir = tempfile::tempdir().unwrap();
196        let root = dir.path();
197        touch(&root.join("outside/secret.txt"));
198        let link = root.join("root-link");
199        std::os::unix::fs::symlink(root.join("outside"), &link).unwrap();
200
201        let err = walk_division(&link, &WalkOptions::default()).unwrap_err();
202        assert!(err.to_string().contains("symlink"));
203    }
204}