1use std::path::{Path, PathBuf};
4
5use walkdir::WalkDir;
6
7use crate::{Error, Result};
8
9#[derive(Debug, Clone)]
21pub struct WalkOptions {
22 pub ignore_names: Vec<String>,
24 pub ignore_suffixes: Vec<String>,
27}
28
29impl Default for WalkOptions {
30 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum EntryKind {
57 File,
59 Symlink,
61 Other,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct WalkedEntry {
68 pub path: PathBuf,
70 pub kind: EntryKind,
72}
73
74pub 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 #[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 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 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}