Skip to main content

strypt_core/
walk.rs

1//! Expanding a folder into the files a batch processes, shared so every front-end descends alike.
2//!
3//! Nothing is passed over silently: each entry is a file to process or a [`Skipped`] with its
4//! reason, because a batch that quietly omits a file lets the user believe it was cleaned.
5
6use std::path::{Path, PathBuf};
7
8/// Why [`walk`] passed over an entry.
9#[derive(Debug)]
10#[non_exhaustive]
11pub enum Skip {
12    /// Not followed: a link could redirect a batch, and an in-place strip, outside the folder
13    /// the user named.
14    SymbolicLink,
15    /// A pipe, socket or device. Reading a pipe waits for a writer that may never come.
16    NotAFile,
17    /// The folder or entry could not be read.
18    Unreadable(std::io::Error),
19}
20
21/// One entry [`walk`] did not return as a file.
22#[derive(Debug)]
23pub struct Skipped {
24    /// The entry, or the folder whose listing failed.
25    pub path: PathBuf,
26    /// Why.
27    pub reason: Skip,
28}
29
30/// What a folder holds.
31#[derive(Debug, Default)]
32pub struct Walk {
33    /// Regular files, sorted, so a batch's order and outcome do not depend on the filesystem's.
34    pub files: Vec<PathBuf>,
35    /// Everything else, sorted by path.
36    pub skipped: Vec<Skipped>,
37    /// Folders listed, the root included.
38    pub folders: usize,
39}
40
41/// Every regular file under `root`, at any depth, never following a symbolic link.
42///
43/// Iterative, so a deep tree cannot exhaust the stack.
44#[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            // `DirEntry::file_type` does not follow links, so a link is seen as one.
67            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        // Root reads a folder whatever its mode, so there is nothing to observe.
191        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}