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//! The canonical module root is carried through all recursion so
6//! containment checks are never relative to the current directory.
7//! Directory symlinks are rejected to prevent cycles. File symlinks
8//! are allowed when they resolve inside the module root and do not
9//! target non-module content.
10
11use std::io;
12use std::path::Path;
13use std::path::PathBuf;
14
15use thiserror::Error;
16
17use crate::hash::NON_MODULE_CONTENT;
18
19/// An error encountered while walking a module tree.
20#[derive(Debug, Error)]
21pub enum ModuleWalkError {
22    /// A symbolic link target resolves outside the module root.
23    #[error("symbolic link `{0}` resolves outside the module root")]
24    SymlinkEscapesRoot(String),
25
26    /// A symbolic link points to a directory.
27    ///
28    /// Directory symlinks are rejected to prevent cycles during tree
29    /// traversal.
30    #[error("symbolic link `{0}` targets a directory")]
31    DirectorySymlink(String),
32
33    /// A symbolic link resolves to a path that is not UTF-8.
34    #[error("symbolic link target under `{0}` is not UTF-8")]
35    NonUtf8SymlinkTarget(String),
36
37    /// A symbolic link target resolves to non-module content (e.g.,
38    /// `.git` or `.sparse.json`).
39    #[error("symbolic link `{0}` targets non-module content")]
40    SymlinkTargetsMetadata(String),
41
42    /// I/O failure during the walk.
43    #[error("i/o error at `{path}`")]
44    Io {
45        /// The path involved.
46        path: PathBuf,
47        /// The underlying I/O error.
48        #[source]
49        source: io::Error,
50    },
51}
52
53/// Statistics collected during a tree walk.
54#[derive(Clone, Debug, Default)]
55pub struct TreeStats {
56    /// Total regular files encountered.
57    pub files: usize,
58    /// Total bytes of regular files.
59    pub bytes: u64,
60}
61
62/// Walks every regular file under `root`, enforcing containment and
63/// metadata exclusion. Calls `visitor` for each file with its path
64/// and size. Returns aggregate statistics.
65///
66/// Rules enforced:
67/// - Entries named `.git` or `.sparse.json` are skipped.
68/// - Symlinks whose canonical target is outside the module root are rejected
69///   with `ModuleWalkError::SymlinkEscapesRoot`.
70/// - Symlinks targeting non-module content (`.git`, `.sparse.json`) are
71///   rejected with `ModuleWalkError::SymlinkTargetsMetadata`.
72/// - Directory symlinks are rejected to prevent cycles.
73/// - Only regular files (and file symlinks to valid targets) are visited.
74pub fn walk_module_tree<E>(
75    root: &Path,
76    visitor: &mut dyn FnMut(&Path, u64) -> Result<(), E>,
77) -> Result<TreeStats, WalkError<E>> {
78    let canonical_root = std::fs::canonicalize(root).map_err(|source| {
79        WalkError::Walk(ModuleWalkError::Io {
80            path: root.to_path_buf(),
81            source,
82        })
83    })?;
84    let mut stats = TreeStats::default();
85    walk_recursive(&canonical_root, root, visitor, &mut stats)?;
86    Ok(stats)
87}
88
89/// The error type for [`walk_module_tree`]. Wraps both walk-layer
90/// errors and visitor errors.
91#[derive(Debug)]
92pub enum WalkError<E> {
93    /// An error encountered by the walker itself.
94    Walk(ModuleWalkError),
95    /// An error returned by the visitor callback.
96    Visitor(E),
97}
98
99impl<E> From<ModuleWalkError> for WalkError<E> {
100    fn from(e: ModuleWalkError) -> Self {
101        Self::Walk(e)
102    }
103}
104
105/// Recursive directory walker carrying the canonical module root.
106fn walk_recursive<E>(
107    module_root: &Path,
108    dir: &Path,
109    visitor: &mut dyn FnMut(&Path, u64) -> Result<(), E>,
110    stats: &mut TreeStats,
111) -> Result<(), WalkError<E>> {
112    let entries = std::fs::read_dir(dir).map_err(|source| {
113        WalkError::Walk(ModuleWalkError::Io {
114            path: dir.to_path_buf(),
115            source,
116        })
117    })?;
118    for entry in entries {
119        let entry = entry.map_err(|source| {
120            WalkError::Walk(ModuleWalkError::Io {
121                path: dir.to_path_buf(),
122                source,
123            })
124        })?;
125        let name = entry.file_name();
126        if NON_MODULE_CONTENT.iter().any(|s| *s == name) {
127            continue;
128        }
129        let path = entry.path();
130        let meta = std::fs::symlink_metadata(&path).map_err(|source| {
131            WalkError::Walk(ModuleWalkError::Io {
132                path: path.to_path_buf(),
133                source,
134            })
135        })?;
136        if meta.file_type().is_symlink() {
137            handle_symlink(module_root, &path, visitor, stats)?;
138            continue;
139        }
140        if meta.is_dir() {
141            walk_recursive(module_root, &path, visitor, stats)?;
142        } else if meta.is_file() {
143            stats.files += 1;
144            stats.bytes = stats.bytes.saturating_add(meta.len());
145            visitor(&path, meta.len()).map_err(WalkError::Visitor)?;
146        }
147    }
148    Ok(())
149}
150
151/// Validates and processes a symlink entry against containment rules.
152fn handle_symlink<E>(
153    module_root: &Path,
154    path: &Path,
155    visitor: &mut dyn FnMut(&Path, u64) -> Result<(), E>,
156    stats: &mut TreeStats,
157) -> Result<(), WalkError<E>> {
158    let target = std::fs::canonicalize(path).map_err(|source| {
159        WalkError::Walk(ModuleWalkError::Io {
160            path: path.to_path_buf(),
161            source,
162        })
163    })?;
164    if !target.starts_with(module_root) {
165        return Err(WalkError::Walk(ModuleWalkError::SymlinkEscapesRoot(
166            path.display().to_string(),
167        )));
168    }
169    if let Ok(rel) = target.strip_prefix(module_root) {
170        if rel.to_str().is_none() {
171            return Err(WalkError::Walk(ModuleWalkError::NonUtf8SymlinkTarget(
172                path.display().to_string(),
173            )));
174        }
175        // SAFETY: the `to_str` check above guarantees all components
176        // are valid UTF-8.
177        let targets_metadata = rel
178            .components()
179            .any(|c| NON_MODULE_CONTENT.contains(&c.as_os_str().to_str().unwrap()));
180        if targets_metadata {
181            return Err(WalkError::Walk(ModuleWalkError::SymlinkTargetsMetadata(
182                path.display().to_string(),
183            )));
184        }
185    }
186    let target_meta = std::fs::metadata(path).map_err(|source| {
187        WalkError::Walk(ModuleWalkError::Io {
188            path: path.to_path_buf(),
189            source,
190        })
191    })?;
192    if target_meta.is_dir() {
193        return Err(WalkError::Walk(ModuleWalkError::DirectorySymlink(
194            path.display().to_string(),
195        )));
196    }
197    if target_meta.is_file() {
198        stats.files += 1;
199        stats.bytes = stats.bytes.saturating_add(target_meta.len());
200        visitor(path, target_meta.len()).map_err(WalkError::Visitor)?;
201    }
202    Ok(())
203}