1use std::path::{Path, PathBuf};
7
8#[derive(Debug)]
10#[non_exhaustive]
11pub enum Skip {
12 SymbolicLink,
15 NotAFile,
17 Unreadable(std::io::Error),
19}
20
21#[derive(Debug)]
23pub struct Skipped {
24 pub path: PathBuf,
26 pub reason: Skip,
28}
29
30#[derive(Debug, Default)]
32pub struct Walk {
33 pub files: Vec<PathBuf>,
35 pub skipped: Vec<Skipped>,
37 pub folders: usize,
39}
40
41#[must_use]
45pub fn walk(root: &Path) -> Walk {
46 let mut walk = Walk::default();
47 let mut pending = vec![root.to_path_buf()];
48 while let Some(dir) = pending.pop() {
49 let entries = match std::fs::read_dir(&dir) {
50 Ok(entries) => entries,
51 Err(error) => {
52 walk.skip(dir, Skip::Unreadable(error));
53 continue;
54 }
55 };
56 walk.folders = walk.folders.saturating_add(1);
57 for entry in entries {
58 let entry = match entry {
59 Ok(entry) => entry,
60 Err(error) => {
61 walk.skip(dir.clone(), Skip::Unreadable(error));
62 continue;
63 }
64 };
65 let path = entry.path();
66 match entry.file_type() {
68 Ok(kind) if kind.is_symlink() => walk.skip(path, Skip::SymbolicLink),
69 Ok(kind) if kind.is_dir() => pending.push(path),
70 Ok(kind) if kind.is_file() => walk.files.push(path),
71 Ok(_) => walk.skip(path, Skip::NotAFile),
72 Err(error) => walk.skip(path, Skip::Unreadable(error)),
73 }
74 }
75 }
76 walk.files.sort();
77 walk.skipped.sort_by(|a, b| a.path.cmp(&b.path));
78 walk
79}
80
81impl Walk {
82 fn skip(&mut self, path: PathBuf, reason: Skip) {
83 self.skipped.push(Skipped { path, reason });
84 }
85}
86
87#[cfg(test)]
88mod tests {
89 #![allow(clippy::unwrap_used)]
90
91 use super::*;
92
93 struct Scratch(PathBuf);
94
95 impl Scratch {
96 fn new(tag: &str) -> Self {
97 let path = std::env::temp_dir().join(format!(
98 "strypt-walk-{tag}-{}-{:?}",
99 std::process::id(),
100 std::thread::current().id()
101 ));
102 let _ = std::fs::remove_dir_all(&path);
103 std::fs::create_dir_all(&path).unwrap();
104 Self(path)
105 }
106 fn file(&self, rel: &str) -> PathBuf {
107 let path = self.0.join(rel);
108 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
109 std::fs::write(&path, b"x").unwrap();
110 path
111 }
112 }
113
114 impl Drop for Scratch {
115 fn drop(&mut self) {
116 let _ = std::fs::remove_dir_all(&self.0);
117 }
118 }
119
120 #[test]
121 fn nested_files_are_found_sorted_and_folders_counted() {
122 let dir = Scratch::new("nested");
123 let deep = dir.file("b/c/d.jpg");
124 let top = dir.file("a.jpg");
125 let mid = dir.file("b/e.png");
126 std::fs::create_dir(dir.0.join("empty")).unwrap();
127 let got = walk(&dir.0);
128 assert_eq!(got.files, [top, deep, mid]);
129 assert_eq!(got.folders, 4);
130 assert!(got.skipped.is_empty());
131 }
132
133 #[test]
134 fn a_missing_root_is_reported_not_ignored() {
135 let got = walk(Path::new("/nonexistent/strypt/walk"));
136 assert!(got.files.is_empty());
137 assert!(matches!(
138 got.skipped.as_slice(),
139 [Skipped {
140 reason: Skip::Unreadable(_),
141 ..
142 }]
143 ));
144 assert_eq!(got.folders, 0);
145 }
146
147 #[cfg(unix)]
148 #[test]
149 fn a_link_is_reported_and_not_followed() {
150 let dir = Scratch::new("link");
151 let outside = Scratch::new("link-target");
152 outside.file("secret.jpg");
153 let link = dir.0.join("link");
154 std::os::unix::fs::symlink(&outside.0, &link).unwrap();
155 let got = walk(&dir.0);
156 assert!(got.files.is_empty());
157 assert!(matches!(
158 got.skipped.as_slice(),
159 [Skipped { path, reason: Skip::SymbolicLink }] if *path == link
160 ));
161 }
162
163 #[cfg(unix)]
164 #[test]
165 fn a_pipe_is_skipped_rather_than_read() {
166 let dir = Scratch::new("fifo");
167 let fifo = dir.0.join("pipe");
168 let made = std::process::Command::new("mkfifo")
169 .arg(&fifo)
170 .status()
171 .unwrap();
172 assert!(made.success());
173 let got = walk(&dir.0);
174 assert!(got.files.is_empty());
175 assert!(matches!(
176 got.skipped.as_slice(),
177 [Skipped { path, reason: Skip::NotAFile }] if *path == fifo
178 ));
179 }
180
181 #[cfg(unix)]
182 #[test]
183 fn an_unreadable_folder_is_reported_and_its_siblings_still_found() {
184 use std::os::unix::fs::PermissionsExt as _;
185 let dir = Scratch::new("locked");
186 let kept = dir.file("open/a.jpg");
187 dir.file("locked/b.jpg");
188 let locked = dir.0.join("locked");
189 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
190 if std::fs::read_dir(&locked).is_ok() {
192 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
193 return;
194 }
195 let got = walk(&dir.0);
196 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
197 assert_eq!(got.files, [kept]);
198 assert!(matches!(
199 got.skipped.as_slice(),
200 [Skipped { path, reason: Skip::Unreadable(_) }] if *path == locked
201 ));
202 }
203}