Skip to main content

hypha/
tree.rs

1//! Filesystem traversal that produces `TreeEntry` lists for substrate tree hashing.
2//!
3//! Implements [`substrate::DirReader`] for real filesystem I/O, delegating
4//! filtering decisions (exclude_names, follow_rules) to substrate.
5
6use std::fs;
7use std::path::Path;
8
9use anyhow::Result;
10use ignore::Match;
11use ignore::gitignore::{Gitignore, GitignoreBuilder};
12use substrate::{DirReader, TreeEntry};
13
14/// Real filesystem reader with hierarchical gitignore-style follow_rules support.
15///
16/// `follow_rules` names ignore files (e.g. `.gitignore`) that are honored at
17/// EVERY directory level, matching git: a `.gitignore` in a subdirectory applies
18/// to that subtree, and a deeper file overrides a shallower one. The matchers are
19/// discovered by walking the tree once here, skipping `exclude_names` directories,
20/// symlinks, and any subtree already ignored by a shallower rule (so large
21/// build-artifact trees like `node_modules` are never descended into).
22pub struct FsReader {
23    /// One matcher per directory that holds a follow-rule file, ordered
24    /// shallowest-first so deeper rules take precedence.
25    gitignores: Vec<Gitignore>,
26}
27
28impl FsReader {
29    pub fn new(root_path: &Path, exclude_names: &[String], follow_rules: &[String]) -> Self {
30        let mut gitignores = Vec::new();
31        if !follow_rules.is_empty() {
32            discover_follow_rules(root_path, exclude_names, follow_rules, &mut gitignores);
33        }
34        Self { gitignores }
35    }
36}
37
38impl substrate::DirReader for FsReader {
39    fn read_dir(&self, path: &Path) -> Result<Vec<substrate::DirEntry>> {
40        let mut entries = Vec::new();
41        for entry in fs::read_dir(path)? {
42            let entry = entry?;
43            let path = entry.path();
44            let name = entry.file_name().to_string_lossy().to_string();
45            let file_type = fs::symlink_metadata(&path)?.file_type();
46
47            // Symlinks and special files are skipped during tree walk.
48            // Use check_no_symlinks() before walk/hash to reject symlinks
49            // with a clear error (respecting exclude_names and follow_rules).
50            if file_type.is_symlink() || (!file_type.is_file() && !file_type.is_dir()) {
51                continue;
52            }
53
54            entries.push(substrate::DirEntry {
55                name,
56                is_dir: file_type.is_dir(),
57                is_file: file_type.is_file(),
58            });
59        }
60        Ok(entries)
61    }
62
63    fn read_file(&self, path: &Path) -> Result<Vec<u8>> {
64        Ok(fs::read(path)?)
65    }
66
67    fn is_executable(&self, path: &Path) -> Result<bool> {
68        #[cfg(unix)]
69        {
70            use std::os::unix::fs::PermissionsExt;
71            let metadata = fs::metadata(path)?;
72            Ok(metadata.permissions().mode() & 0o111 != 0)
73        }
74
75        #[cfg(not(unix))]
76        {
77            let _ = path;
78            Ok(false)
79        }
80    }
81
82    fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
83        path_is_ignored(&self.gitignores, path, is_dir)
84    }
85
86    fn mtime_ms(&self, path: &Path) -> Result<Option<u64>> {
87        let meta = fs::metadata(path)?;
88        Ok(meta
89            .modified()
90            .ok()
91            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
92            .map(|d| d.as_millis() as u64))
93    }
94}
95
96/// Evaluate `path` against every ancestor directory's matcher, shallowest-first,
97/// so a deeper `.gitignore` overrides a shallower one (git semantics: the last
98/// matching rule, from the closest file, decides).
99fn path_is_ignored(gitignores: &[Gitignore], path: &Path, is_dir: bool) -> bool {
100    let mut ignored = false;
101    for gi in gitignores {
102        if path.starts_with(gi.path()) {
103            match gi.matched_path_or_any_parents(path, is_dir) {
104                Match::Ignore(_) => ignored = true,
105                Match::Whitelist(_) => ignored = false,
106                Match::None => {}
107            }
108        }
109    }
110    ignored
111}
112
113/// Walk `dir` (pre-order) collecting one gitignore matcher per directory that
114/// holds a follow-rule file, so the rules are honored hierarchically like git.
115/// Skips `exclude_names` directories, symlinks, and subtrees already ignored by a
116/// shallower rule — the latter means a build-artifact tree (e.g. `node_modules`,
117/// once its parent `.gitignore` is seen) is never descended into.
118fn discover_follow_rules(
119    dir: &Path,
120    exclude_names: &[String],
121    follow_rules: &[String],
122    acc: &mut Vec<Gitignore>,
123) {
124    let mut builder = GitignoreBuilder::new(dir);
125    let mut found = false;
126    for rule_file in follow_rules {
127        let path = dir.join(rule_file);
128        if path.is_file() && builder.add(&path).is_none() {
129            found = true;
130        }
131    }
132    if found && let Ok(gi) = builder.build() {
133        acc.push(gi);
134    }
135
136    let read = match fs::read_dir(dir) {
137        Ok(read) => read,
138        Err(_) => return,
139    };
140    for entry in read.flatten() {
141        let name = entry.file_name().to_string_lossy().to_string();
142        if substrate::tree::should_exclude(&name, exclude_names) {
143            continue;
144        }
145        let path = entry.path();
146        let Ok(file_type) = fs::symlink_metadata(&path).map(|m| m.file_type()) else {
147            continue;
148        };
149        if !file_type.is_dir() || file_type.is_symlink() {
150            continue;
151        }
152        if path_is_ignored(acc, &path, true) {
153            continue;
154        }
155        discover_follow_rules(&path, exclude_names, follow_rules, acc);
156    }
157}
158
159/// Walk a directory and produce in-memory `TreeEntry` values.
160///
161/// `exclude_names` — filenames to skip (exact match).
162/// `follow_rules` — paths (relative to `dir_path`) of gitignore-style rule files.
163pub fn walk_dir(
164    dir_path: &Path,
165    exclude_names: &[String],
166    follow_rules: &[String],
167) -> Result<Vec<TreeEntry>> {
168    let reader = FsReader::new(dir_path, exclude_names, follow_rules);
169    substrate::walk_dir(&reader, dir_path, exclude_names)
170}
171
172/// Convenience: walk + compute hash in one call.
173pub fn compute_tree_hash(dir_path: &Path, tree: &substrate::SporeTree) -> Result<String> {
174    let entries = walk_dir(dir_path, &tree.exclude_names, &tree.follow_rules)?;
175    tree.compute_hash(&entries)
176}
177
178/// Check that a directory tree contains no symlinks (respecting exclude_names and follow_rules).
179///
180/// Returns an error listing the first symlink found, with instructions for the user.
181/// Call this before `release` to catch symlinks early.
182pub fn check_no_symlinks(
183    dir_path: &Path,
184    exclude_names: &[String],
185    follow_rules: &[String],
186) -> Result<()> {
187    let reader = FsReader::new(dir_path, exclude_names, follow_rules);
188    check_no_symlinks_inner(&reader, dir_path, dir_path, exclude_names)
189}
190
191fn check_no_symlinks_inner(
192    reader: &FsReader,
193    root: &Path,
194    dir_path: &Path,
195    exclude_names: &[String],
196) -> Result<()> {
197    for entry in fs::read_dir(dir_path)? {
198        let entry = entry?;
199        let path = entry.path();
200        let name = entry.file_name().to_string_lossy().to_string();
201
202        if substrate::tree::should_exclude(&name, exclude_names) {
203            continue;
204        }
205        let file_type = entry.file_type()?;
206        if reader.is_ignored(&path, file_type.is_dir()) {
207            continue;
208        }
209
210        if file_type.is_symlink() {
211            let target = fs::read_link(&path)
212                .map(|t| t.to_string_lossy().into_owned())
213                .unwrap_or_else(|_| "?".to_string());
214            let relative = path.strip_prefix(root).unwrap_or(&path);
215            anyhow::bail!(
216                "symlink found: {} → {}\n\
217                 Symlinks are not included in spore content.\n  \
218                 To include the target content: cp -L \"{0}\" \"{0}.tmp\" && mv \"{0}.tmp\" \"{0}\"\n  \
219                 To exclude it: add \"{}\" to exclude_names",
220                relative.display(),
221                target,
222                name,
223            );
224        }
225        if file_type.is_dir() {
226            check_no_symlinks_inner(reader, root, &path, exclude_names)?;
227        }
228    }
229    Ok(())
230}
231
232#[cfg(test)]
233#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
234mod tests {
235    use super::*;
236
237    #[cfg(unix)]
238    #[test]
239    fn walk_dir_skips_symlink_entries() {
240        use std::os::unix::fs::symlink;
241
242        let temp = tempfile::tempdir().unwrap();
243        let root = temp.path();
244        let target = root.join("target.txt");
245        let regular = root.join("regular.txt");
246        let symlink_path = root.join("linked.txt");
247
248        std::fs::write(&target, "target").unwrap();
249        std::fs::write(&regular, "regular").unwrap();
250        symlink(&target, &symlink_path).unwrap();
251
252        let entries = walk_dir(root, &[], &[]).unwrap();
253        let flat = substrate::flatten_entries(&entries);
254        let names: Vec<String> = flat.into_iter().map(|(path, _, _)| path).collect();
255
256        assert!(names.contains(&"regular.txt".to_string()));
257        assert!(names.contains(&"target.txt".to_string()));
258        assert!(
259            !names.contains(&"linked.txt".to_string()),
260            "symlink entries must be skipped"
261        );
262    }
263
264    #[cfg(unix)]
265    #[test]
266    fn check_no_symlinks_catches_symlink() {
267        use std::os::unix::fs::symlink;
268
269        let temp = tempfile::tempdir().unwrap();
270        let root = temp.path();
271        std::fs::write(root.join("target.txt"), "target").unwrap();
272        symlink("target.txt", root.join("linked.txt")).unwrap();
273
274        let err = check_no_symlinks(root, &[], &[]).unwrap_err();
275        let msg = err.to_string();
276        assert!(
277            msg.contains("symlink found"),
278            "error should mention symlink: {}",
279            msg
280        );
281        assert!(
282            msg.contains("linked.txt"),
283            "error should name the file: {}",
284            msg
285        );
286    }
287
288    #[cfg(unix)]
289    #[test]
290    fn check_no_symlinks_respects_exclude_names() {
291        use std::os::unix::fs::symlink;
292
293        let temp = tempfile::tempdir().unwrap();
294        let root = temp.path();
295        std::fs::write(root.join("regular.txt"), "data").unwrap();
296        symlink("regular.txt", root.join("linked.txt")).unwrap();
297
298        // Excluding the symlink by name should not error
299        assert!(check_no_symlinks(root, &["linked.txt".to_string()], &[]).is_ok());
300    }
301
302    #[cfg(unix)]
303    #[test]
304    fn check_no_symlinks_honors_nested_gitignore() {
305        use std::os::unix::fs::symlink;
306
307        let temp = tempfile::tempdir().unwrap();
308        let root = temp.path();
309        // A per-language subdir whose own .gitignore excludes node_modules — the
310        // exact shape that broke afdata packaging (typescript/.gitignore holds
311        // `node_modules/`, and node_modules/.bin/tsx is a symlink).
312        let sub = root.join("typescript");
313        std::fs::create_dir(&sub).unwrap();
314        std::fs::write(sub.join(".gitignore"), "node_modules/\n").unwrap();
315        let bin = sub.join("node_modules").join(".bin");
316        std::fs::create_dir_all(&bin).unwrap();
317        std::fs::write(bin.join("target"), "x").unwrap();
318        symlink("target", bin.join("tsx")).unwrap();
319
320        // follow_rules=[".gitignore"] must honor the nested file, so the ignored
321        // node_modules is never descended and its symlink never flagged.
322        assert!(check_no_symlinks(root, &[], &[".gitignore".to_string()]).is_ok());
323    }
324
325    #[test]
326    fn walk_dir_honors_nested_gitignore() {
327        let temp = tempfile::tempdir().unwrap();
328        let root = temp.path();
329        let sub = root.join("pkg");
330        std::fs::create_dir(&sub).unwrap();
331        std::fs::write(sub.join(".gitignore"), "build/\n").unwrap();
332        std::fs::write(sub.join("keep.txt"), "keep").unwrap();
333        std::fs::create_dir(sub.join("build")).unwrap();
334        std::fs::write(sub.join("build").join("out.o"), "obj").unwrap();
335
336        let entries = walk_dir(root, &[], &[".gitignore".to_string()]).unwrap();
337        let names: Vec<String> = substrate::flatten_entries(&entries)
338            .into_iter()
339            .map(|(path, _, _)| path)
340            .collect();
341
342        assert!(
343            names.iter().any(|n| n.ends_with("keep.txt")),
344            "tracked sibling must survive: {names:?}"
345        );
346        assert!(
347            !names.iter().any(|n| n.contains("build")),
348            "nested-gitignored dir must be excluded: {names:?}"
349        );
350    }
351
352    #[test]
353    fn deeper_gitignore_overrides_shallower() {
354        let temp = tempfile::tempdir().unwrap();
355        let root = temp.path();
356        // Root ignores *.log; a subdirectory whitelists them back — a deeper
357        // rule must override the shallower one, like git.
358        std::fs::write(root.join(".gitignore"), "*.log\n").unwrap();
359        let sub = root.join("keep");
360        std::fs::create_dir(&sub).unwrap();
361        std::fs::write(sub.join(".gitignore"), "!*.log\n").unwrap();
362        std::fs::write(root.join("root.log"), "r").unwrap();
363        std::fs::write(sub.join("kept.log"), "k").unwrap();
364
365        let entries = walk_dir(root, &[], &[".gitignore".to_string()]).unwrap();
366        let names: Vec<String> = substrate::flatten_entries(&entries)
367            .into_iter()
368            .map(|(path, _, _)| path)
369            .collect();
370
371        assert!(
372            !names.iter().any(|n| n.ends_with("root.log")),
373            "root-level *.log stays ignored: {names:?}"
374        );
375        assert!(
376            names.iter().any(|n| n.ends_with("kept.log")),
377            "deeper !*.log re-includes it: {names:?}"
378        );
379    }
380}