Skip to main content

wdl_modules/
module_walk.rs

1//! Safe module-content tree walk shared by hashing, verification,
2//! resource-limit checking, and materialization.
3//!
4//! One traversal implementation enforces all module-content rules.
5//! Symbolic links are not permitted anywhere in a module tree: any
6//! symlink encountered during the walk makes the module invalid, per
7//! the module specification.
8
9use std::io;
10use std::path::Path;
11use std::path::PathBuf;
12
13use thiserror::Error;
14
15use crate::hash::NON_MODULE_CONTENT;
16
17/// An error encountered while walking a module tree.
18#[derive(Debug, Error)]
19pub enum ModuleWalkError {
20    /// A symbolic link was found in the module tree. Symbolic links are
21    /// not permitted anywhere in a module.
22    #[error("symbolic link `{0}` is not permitted in a module")]
23    Symlink(String),
24
25    /// I/O failure during the walk.
26    #[error("i/o error at `{path}`")]
27    Io {
28        /// The path involved.
29        path: PathBuf,
30        /// The underlying I/O error.
31        #[source]
32        source: io::Error,
33    },
34}
35
36/// Statistics collected during a tree walk.
37#[derive(Clone, Debug, Default)]
38pub struct TreeStats {
39    /// Total regular files encountered.
40    pub files: usize,
41    /// Total bytes of regular files.
42    pub bytes: u64,
43}
44
45/// Walks every regular file under `root`, enforcing containment and
46/// metadata exclusion. Calls `visitor` for each file with its path
47/// and size. Returns aggregate statistics.
48///
49/// The walk enforces these rules.
50///
51/// - Entries named `.git` or `.sprocket` are skipped.
52/// - Any symbolic link is rejected with [`ModuleWalkError::Symlink`].
53/// - Only regular files are visited.
54pub fn walk_module_tree<E>(
55    root: &Path,
56    visitor: &mut dyn FnMut(&Path, u64) -> Result<(), E>,
57) -> Result<TreeStats, WalkError<E>> {
58    let mut stats = TreeStats::default();
59    walk_recursive(root, visitor, &mut stats)?;
60    Ok(stats)
61}
62
63/// The error type for [`walk_module_tree`]. Wraps both walk-layer
64/// errors and visitor errors.
65#[derive(Debug)]
66pub enum WalkError<E> {
67    /// An error encountered by the walker itself.
68    Walk(ModuleWalkError),
69    /// An error returned by the visitor callback.
70    Visitor(E),
71}
72
73impl<E> From<ModuleWalkError> for WalkError<E> {
74    fn from(e: ModuleWalkError) -> Self {
75        Self::Walk(e)
76    }
77}
78
79/// Recursive directory walker. Rejects any symbolic link encountered.
80fn walk_recursive<E>(
81    dir: &Path,
82    visitor: &mut dyn FnMut(&Path, u64) -> Result<(), E>,
83    stats: &mut TreeStats,
84) -> Result<(), WalkError<E>> {
85    let entries = std::fs::read_dir(dir).map_err(|source| {
86        WalkError::Walk(ModuleWalkError::Io {
87            path: dir.to_path_buf(),
88            source,
89        })
90    })?;
91    for entry in entries {
92        let entry = entry.map_err(|source| {
93            WalkError::Walk(ModuleWalkError::Io {
94                path: dir.to_path_buf(),
95                source,
96            })
97        })?;
98        let name = entry.file_name();
99        let path = entry.path();
100        let meta = std::fs::symlink_metadata(&path).map_err(|source| {
101            WalkError::Walk(ModuleWalkError::Io {
102                path: path.to_path_buf(),
103                source,
104            })
105        })?;
106        // Symbolic links are not permitted anywhere in a module tree.
107        if meta.file_type().is_symlink() {
108            return Err(WalkError::Walk(ModuleWalkError::Symlink(
109                path.display().to_string(),
110            )));
111        }
112        if NON_MODULE_CONTENT.iter().any(|s| *s == name) {
113            continue;
114        }
115        if meta.is_dir() {
116            walk_recursive(&path, visitor, stats)?;
117        } else if meta.is_file() {
118            stats.files += 1;
119            stats.bytes = stats.bytes.saturating_add(meta.len());
120            visitor(&path, meta.len()).map_err(WalkError::Visitor)?;
121        }
122    }
123    Ok(())
124}
125
126#[cfg(all(test, unix))]
127mod tests {
128    use std::convert::Infallible;
129    use std::os::unix::fs::symlink;
130
131    use tempfile::tempdir;
132
133    use super::*;
134
135    #[test]
136    fn rejects_symlink_in_excluded_directory() -> Result<(), Box<dyn std::error::Error>> {
137        let root = tempdir()?;
138        let outside = tempdir()?;
139        symlink(outside.path(), root.path().join(".sprocket"))?;
140
141        let result = walk_module_tree(root.path(), &mut |_, _| -> Result<(), Infallible> {
142            Ok(())
143        });
144
145        assert!(matches!(
146            result,
147            Err(WalkError::Walk(ModuleWalkError::Symlink(_)))
148        ));
149        Ok(())
150    }
151}