Skip to main content

drft/sources/
fs.rs

1//! The `fs` source: a `.gitignore`-aware filesystem walk that yields one
2//! [`SourceFile`] per file under the graph root.
3
4use anyhow::Result;
5use ignore::WalkBuilder;
6use std::path::Path;
7
8use crate::config::compile_globs;
9
10/// A file delivered by a source: its graph-relative path and, when read, its
11/// raw bytes.
12pub struct SourceFile {
13    /// Path relative to the graph root, with forward slashes.
14    pub path: String,
15    /// Whether this entry is a symlink (stat'd once during the walk).
16    pub is_symlink: bool,
17    /// Raw content. `None` when the file exists but its content is intentionally
18    /// not read — a symlink whose target resolves outside the graph root, or a
19    /// file that could not be read.
20    pub bytes: Option<Vec<u8>>,
21}
22
23/// Walk the tree under `root`, honoring `.gitignore` and the `ignore` globs,
24/// yielding one [`SourceFile`] per file. Paths are relative to `root`, sorted.
25///
26/// A symlink is yielded at its own path; its content is read only when the link
27/// resolves within `root`, so a symlink escaping the graph carries no bytes
28/// (and therefore no hash).
29pub fn walk(root: &Path, ignore: &[String]) -> Result<Vec<SourceFile>> {
30    let ignore_set = compile_globs(ignore)?;
31    let canonical_root = root.canonicalize()?;
32
33    let mut files = Vec::new();
34
35    let walker = WalkBuilder::new(root).follow_links(true).build();
36
37    for entry in walker {
38        let entry = entry?;
39        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
40            continue;
41        }
42
43        let relative = entry
44            .path()
45            .strip_prefix(root)
46            .expect("path should be under root")
47            .to_string_lossy()
48            .replace('\\', "/");
49
50        if ignore_set
51            .as_ref()
52            .is_some_and(|set| set.is_match(&relative))
53        {
54            continue;
55        }
56
57        let abs = root.join(&relative);
58        let is_symlink = abs
59            .symlink_metadata()
60            .is_ok_and(|m| m.file_type().is_symlink());
61
62        let bytes = if is_symlink {
63            // Read content only when the link resolves within the graph root.
64            match abs.canonicalize() {
65                Ok(canonical) if canonical.starts_with(&canonical_root) => std::fs::read(&abs).ok(),
66                _ => None,
67            }
68        } else {
69            std::fs::read(&abs).ok()
70        };
71
72        files.push(SourceFile {
73            path: relative,
74            is_symlink,
75            bytes,
76        });
77    }
78
79    files.sort_by(|a, b| a.path.cmp(&b.path));
80    Ok(files)
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use std::fs;
87    use tempfile::TempDir;
88
89    #[test]
90    fn walks_all_files_sorted() {
91        let dir = TempDir::new().unwrap();
92        fs::write(dir.path().join("b.md"), "b").unwrap();
93        fs::write(dir.path().join("a.md"), "a").unwrap();
94        fs::write(dir.path().join("notes.txt"), "n").unwrap();
95
96        let files = walk(dir.path(), &[]).unwrap();
97        let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
98        assert_eq!(paths, vec!["a.md", "b.md", "notes.txt"]);
99        assert_eq!(files[0].bytes.as_deref(), Some(&b"a"[..]));
100    }
101
102    #[test]
103    fn respects_ignore_globs() {
104        let dir = TempDir::new().unwrap();
105        fs::write(dir.path().join("keep.md"), "k").unwrap();
106        let sub = dir.path().join("target");
107        fs::create_dir(&sub).unwrap();
108        fs::write(sub.join("build.md"), "b").unwrap();
109
110        let files = walk(dir.path(), &["target/**".to_string()]).unwrap();
111        let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
112        assert_eq!(paths, vec!["keep.md"]);
113    }
114
115    #[test]
116    fn respects_gitignore() {
117        let dir = TempDir::new().unwrap();
118        fs::create_dir(dir.path().join(".git")).unwrap();
119        fs::write(dir.path().join(".gitignore"), "vendor/\n").unwrap();
120        fs::write(dir.path().join("index.md"), "i").unwrap();
121        let vendor = dir.path().join("vendor");
122        fs::create_dir(&vendor).unwrap();
123        fs::write(vendor.join("lib.md"), "v").unwrap();
124
125        let files = walk(dir.path(), &[]).unwrap();
126        let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
127        assert!(paths.contains(&"index.md"));
128        assert!(!paths.iter().any(|p| p.contains("vendor")));
129    }
130
131    #[cfg(unix)]
132    #[test]
133    fn symlink_escaping_root_has_no_bytes() {
134        let outer = TempDir::new().unwrap();
135        let root = outer.path().join("project");
136        fs::create_dir(&root).unwrap();
137        fs::write(root.join("index.md"), "i").unwrap();
138        fs::write(outer.path().join("secret.md"), "secret").unwrap();
139        std::os::unix::fs::symlink(outer.path().join("secret.md"), root.join("trap.md")).unwrap();
140
141        let files = walk(&root, &[]).unwrap();
142        let trap = files.iter().find(|f| f.path == "trap.md").unwrap();
143        assert!(
144            trap.bytes.is_none(),
145            "escaping symlink should carry no bytes"
146        );
147    }
148}