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::gitignore::{Gitignore, GitignoreBuilder};
11use substrate::{DirReader, TreeEntry};
12
13/// Real filesystem reader with gitignore-style follow_rules support.
14pub struct FsReader {
15    gitignore: Option<Gitignore>,
16}
17
18impl FsReader {
19    pub fn new(root_path: &Path, follow_rules: &[String]) -> Self {
20        Self {
21            gitignore: build_follow_rules(root_path, follow_rules),
22        }
23    }
24}
25
26impl substrate::DirReader for FsReader {
27    fn read_dir(&self, path: &Path) -> Result<Vec<substrate::DirEntry>> {
28        let mut entries = Vec::new();
29        for entry in fs::read_dir(path)? {
30            let entry = entry?;
31            let path = entry.path();
32            let name = entry.file_name().to_string_lossy().to_string();
33            let file_type = fs::symlink_metadata(&path)?.file_type();
34
35            // Symlinks and special files are skipped during tree walk.
36            // Use check_no_symlinks() before walk/hash to reject symlinks
37            // with a clear error (respecting exclude_names and follow_rules).
38            if file_type.is_symlink() || (!file_type.is_file() && !file_type.is_dir()) {
39                continue;
40            }
41
42            entries.push(substrate::DirEntry {
43                name,
44                is_dir: file_type.is_dir(),
45                is_file: file_type.is_file(),
46            });
47        }
48        Ok(entries)
49    }
50
51    fn read_file(&self, path: &Path) -> Result<Vec<u8>> {
52        Ok(fs::read(path)?)
53    }
54
55    fn is_executable(&self, path: &Path) -> Result<bool> {
56        #[cfg(unix)]
57        {
58            use std::os::unix::fs::PermissionsExt;
59            let metadata = fs::metadata(path)?;
60            Ok(metadata.permissions().mode() & 0o111 != 0)
61        }
62
63        #[cfg(not(unix))]
64        {
65            let _ = path;
66            Ok(false)
67        }
68    }
69
70    fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
71        match &self.gitignore {
72            Some(gi) => gi.matched_path_or_any_parents(path, is_dir).is_ignore(),
73            None => false,
74        }
75    }
76
77    fn mtime_ms(&self, path: &Path) -> Result<Option<u64>> {
78        let meta = fs::metadata(path)?;
79        Ok(meta
80            .modified()
81            .ok()
82            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
83            .map(|d| d.as_millis() as u64))
84    }
85}
86
87fn build_follow_rules(root_path: &Path, follow_rules: &[String]) -> Option<Gitignore> {
88    if follow_rules.is_empty() {
89        return None;
90    }
91
92    let mut builder = GitignoreBuilder::new(root_path);
93    let mut found_any = false;
94
95    for rule_file in follow_rules {
96        let path = root_path.join(rule_file);
97        if path.exists() && builder.add(&path).is_none() {
98            found_any = true;
99        }
100    }
101
102    if !found_any {
103        return None;
104    }
105
106    builder.build().ok()
107}
108
109/// Walk a directory and produce in-memory `TreeEntry` values.
110///
111/// `exclude_names` — filenames to skip (exact match).
112/// `follow_rules` — paths (relative to `dir_path`) of gitignore-style rule files.
113pub fn walk_dir(
114    dir_path: &Path,
115    exclude_names: &[String],
116    follow_rules: &[String],
117) -> Result<Vec<TreeEntry>> {
118    let reader = FsReader::new(dir_path, follow_rules);
119    substrate::walk_dir(&reader, dir_path, exclude_names)
120}
121
122/// Convenience: walk + compute hash in one call.
123pub fn compute_tree_hash(dir_path: &Path, tree: &substrate::SporeTree) -> Result<String> {
124    let entries = walk_dir(dir_path, &tree.exclude_names, &tree.follow_rules)?;
125    tree.compute_hash(&entries)
126}
127
128/// Check that a directory tree contains no symlinks (respecting exclude_names and follow_rules).
129///
130/// Returns an error listing the first symlink found, with instructions for the user.
131/// Call this before `release` to catch symlinks early.
132pub fn check_no_symlinks(
133    dir_path: &Path,
134    exclude_names: &[String],
135    follow_rules: &[String],
136) -> Result<()> {
137    let reader = FsReader::new(dir_path, follow_rules);
138    check_no_symlinks_inner(&reader, dir_path, dir_path, exclude_names)
139}
140
141fn check_no_symlinks_inner(
142    reader: &FsReader,
143    root: &Path,
144    dir_path: &Path,
145    exclude_names: &[String],
146) -> Result<()> {
147    for entry in fs::read_dir(dir_path)? {
148        let entry = entry?;
149        let path = entry.path();
150        let name = entry.file_name().to_string_lossy().to_string();
151
152        if substrate::tree::should_exclude(&name, exclude_names) {
153            continue;
154        }
155        if reader.is_ignored(&path, false) {
156            continue;
157        }
158
159        let file_type = fs::symlink_metadata(&path)?.file_type();
160        if file_type.is_symlink() {
161            let target = fs::read_link(&path)
162                .map(|t| t.to_string_lossy().into_owned())
163                .unwrap_or_else(|_| "?".to_string());
164            let relative = path.strip_prefix(root).unwrap_or(&path);
165            anyhow::bail!(
166                "symlink found: {} → {}\n\
167                 Symlinks are not included in spore content.\n  \
168                 To include the target content: cp -L \"{0}\" \"{0}.tmp\" && mv \"{0}.tmp\" \"{0}\"\n  \
169                 To exclude it: add \"{}\" to exclude_names",
170                relative.display(), target, name,
171            );
172        }
173        if file_type.is_dir() {
174            check_no_symlinks_inner(reader, root, &path, exclude_names)?;
175        }
176    }
177    Ok(())
178}
179
180#[cfg(test)]
181#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
182mod tests {
183    use super::*;
184
185    #[cfg(unix)]
186    #[test]
187    fn walk_dir_skips_symlink_entries() {
188        use std::os::unix::fs::symlink;
189
190        let temp = tempfile::tempdir().unwrap();
191        let root = temp.path();
192        let target = root.join("target.txt");
193        let regular = root.join("regular.txt");
194        let symlink_path = root.join("linked.txt");
195
196        std::fs::write(&target, "target").unwrap();
197        std::fs::write(&regular, "regular").unwrap();
198        symlink(&target, &symlink_path).unwrap();
199
200        let entries = walk_dir(root, &[], &[]).unwrap();
201        let flat = substrate::flatten_entries(&entries);
202        let names: Vec<String> = flat.into_iter().map(|(path, _, _)| path).collect();
203
204        assert!(names.contains(&"regular.txt".to_string()));
205        assert!(names.contains(&"target.txt".to_string()));
206        assert!(
207            !names.contains(&"linked.txt".to_string()),
208            "symlink entries must be skipped"
209        );
210    }
211
212    #[cfg(unix)]
213    #[test]
214    fn check_no_symlinks_catches_symlink() {
215        use std::os::unix::fs::symlink;
216
217        let temp = tempfile::tempdir().unwrap();
218        let root = temp.path();
219        std::fs::write(root.join("target.txt"), "target").unwrap();
220        symlink("target.txt", root.join("linked.txt")).unwrap();
221
222        let err = check_no_symlinks(root, &[], &[]).unwrap_err();
223        let msg = err.to_string();
224        assert!(
225            msg.contains("symlink found"),
226            "error should mention symlink: {}",
227            msg
228        );
229        assert!(
230            msg.contains("linked.txt"),
231            "error should name the file: {}",
232            msg
233        );
234    }
235
236    #[cfg(unix)]
237    #[test]
238    fn check_no_symlinks_respects_exclude_names() {
239        use std::os::unix::fs::symlink;
240
241        let temp = tempfile::tempdir().unwrap();
242        let root = temp.path();
243        std::fs::write(root.join("regular.txt"), "data").unwrap();
244        symlink("regular.txt", root.join("linked.txt")).unwrap();
245
246        // Excluding the symlink by name should not error
247        assert!(check_no_symlinks(root, &["linked.txt".to_string()], &[]).is_ok());
248    }
249}