Skip to main content

drft/sources/
fs.rs

1//! The `fs` source: a `.gitignore`-aware filesystem walk that yields one
2//! [`SourceFile`] per file, symlink, and directory under the graph root.
3
4use anyhow::Result;
5use globset::GlobSet;
6use ignore::WalkBuilder;
7use std::path::Path;
8
9use crate::config::compile_globs;
10
11/// What kind of filesystem entry a [`SourceFile`] is. Derived from `lstat`, so a
12/// symlink-to-directory is [`Symlink`](NodeKind::Symlink) (indirection wins over
13/// target kind), and [`Dir`](NodeKind::Dir) always means a real directory.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum NodeKind {
16    File,
17    Symlink,
18    Dir,
19}
20
21/// An entry delivered by a source: its graph-relative path, kind, and — for
22/// files — its raw bytes.
23pub struct SourceFile {
24    /// Path relative to the graph root, with forward slashes.
25    pub path: String,
26    /// The kind of entry, from `lstat`.
27    pub kind: NodeKind,
28    /// Raw content. `Some` only for files (and `None` for one that could not be
29    /// read). Symlinks and directories are untrackable and always carry `None`.
30    pub bytes: Option<Vec<u8>>,
31}
32
33/// Walk the tree under `root`, honoring `.gitignore` and the `ignore` globs,
34/// yielding one [`SourceFile`] per file, symlink, and directory. Paths are
35/// relative to `root`, sorted.
36///
37/// The walk does not follow symlinks: a symlink is a leaf node at its own path,
38/// never traversed through. Its relationship to its target is carried by the
39/// edge the builder emits, not by re-walking the target. So a symlink to a
40/// directory does not duplicate that directory's subtree, and a symlink to a
41/// path outside the root never pulls outside content into the graph.
42///
43/// Only files carry bytes (and therefore a hash). Symlinks and directories are
44/// untrackable: they resolve link targets but are never hashed or locked.
45pub fn walk(root: &Path, ignore: &[String]) -> Result<Vec<SourceFile>> {
46    let ignore_set = compile_globs(ignore)?;
47
48    let mut files = Vec::new();
49
50    let walker = WalkBuilder::new(root).follow_links(false).build();
51
52    for entry in walker {
53        let entry = entry?;
54        let ft = entry.file_type();
55        // Yield files, symlinks, and directories; skip fifos, sockets, and other
56        // entries. With symlinks unfollowed, a symlink reports its own type here.
57        if !ft.is_some_and(|t| t.is_file() || t.is_dir() || t.is_symlink()) {
58            continue;
59        }
60
61        let relative = entry
62            .path()
63            .strip_prefix(root)
64            .expect("path should be under root")
65            .to_string_lossy()
66            .replace('\\', "/");
67
68        // The root itself is the graph, not a node in it.
69        if relative.is_empty() {
70            continue;
71        }
72
73        // Type from `lstat`, symlink first: a symlink-to-dir is a symlink, and
74        // `Dir` always means a real directory.
75        let kind = match abs_lstat(root, &relative) {
76            Some(m) if m.file_type().is_symlink() => NodeKind::Symlink,
77            Some(m) if m.is_dir() => NodeKind::Dir,
78            _ => NodeKind::File,
79        };
80
81        if is_ignored(&ignore_set, &relative, kind) {
82            continue;
83        }
84
85        // Only files carry content. A symlink's content is its target's, reached
86        // through the edge — the symlink node itself stays untrackable.
87        let bytes = match kind {
88            NodeKind::File => std::fs::read(root.join(&relative)).ok(),
89            NodeKind::Symlink | NodeKind::Dir => None,
90        };
91
92        files.push(SourceFile {
93            path: relative,
94            kind,
95            bytes,
96        });
97    }
98
99    files.sort_by(|a, b| a.path.cmp(&b.path));
100    Ok(files)
101}
102
103/// `lstat` the entry at `root/relative` without following symlinks.
104fn abs_lstat(root: &Path, relative: &str) -> Option<std::fs::Metadata> {
105    root.join(relative).symlink_metadata().ok()
106}
107
108/// Whether an entry is excluded by the `ignore` globs. Files match the path
109/// as-is. A directory is also excluded when a glob matches its path with a
110/// trailing slash — so `examples/**` (which matches `examples/`, not `examples`)
111/// drops the `examples` directory node, while `docs/*.md` leaves `docs` intact.
112fn is_ignored(ignore_set: &Option<GlobSet>, relative: &str, kind: NodeKind) -> bool {
113    let Some(set) = ignore_set else {
114        return false;
115    };
116    set.is_match(relative) || (kind == NodeKind::Dir && set.is_match(format!("{relative}/")))
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use std::fs;
123    use tempfile::TempDir;
124
125    #[test]
126    fn walks_all_files_sorted() {
127        let dir = TempDir::new().unwrap();
128        fs::write(dir.path().join("b.md"), "b").unwrap();
129        fs::write(dir.path().join("a.md"), "a").unwrap();
130        fs::write(dir.path().join("notes.txt"), "n").unwrap();
131
132        let files = walk(dir.path(), &[]).unwrap();
133        let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
134        assert_eq!(paths, vec!["a.md", "b.md", "notes.txt"]);
135        assert_eq!(files[0].bytes.as_deref(), Some(&b"a"[..]));
136    }
137
138    #[test]
139    fn respects_ignore_globs() {
140        let dir = TempDir::new().unwrap();
141        fs::write(dir.path().join("keep.md"), "k").unwrap();
142        let sub = dir.path().join("target");
143        fs::create_dir(&sub).unwrap();
144        fs::write(sub.join("build.md"), "b").unwrap();
145
146        let files = walk(dir.path(), &["target/**".to_string()]).unwrap();
147        let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
148        // `target/**` drops both the build file and the `target` directory node
149        // (it matches `target/`), leaving only the kept file.
150        assert_eq!(paths, vec!["keep.md"]);
151    }
152
153    #[test]
154    fn yields_directory_nodes() {
155        let dir = TempDir::new().unwrap();
156        fs::create_dir(dir.path().join("guides")).unwrap();
157        fs::write(dir.path().join("guides/intro.md"), "i").unwrap();
158
159        let files = walk(dir.path(), &[]).unwrap();
160        let guides = files.iter().find(|f| f.path == "guides").unwrap();
161        assert_eq!(guides.kind, NodeKind::Dir);
162        assert!(guides.bytes.is_none(), "directories carry no content");
163
164        let intro = files.iter().find(|f| f.path == "guides/intro.md").unwrap();
165        assert_eq!(intro.kind, NodeKind::File);
166    }
167
168    #[test]
169    fn includes_empty_directories() {
170        let dir = TempDir::new().unwrap();
171        fs::create_dir(dir.path().join("empty")).unwrap();
172
173        let files = walk(dir.path(), &[]).unwrap();
174        assert!(
175            files
176                .iter()
177                .any(|f| f.path == "empty" && f.kind == NodeKind::Dir)
178        );
179    }
180
181    #[test]
182    fn root_is_not_a_node() {
183        let dir = TempDir::new().unwrap();
184        fs::write(dir.path().join("a.md"), "a").unwrap();
185
186        let files = walk(dir.path(), &[]).unwrap();
187        assert!(
188            !files.iter().any(|f| f.path.is_empty()),
189            "the graph root must not appear as a node"
190        );
191    }
192
193    #[test]
194    fn respects_gitignore() {
195        let dir = TempDir::new().unwrap();
196        fs::create_dir(dir.path().join(".git")).unwrap();
197        fs::write(dir.path().join(".gitignore"), "vendor/\n").unwrap();
198        fs::write(dir.path().join("index.md"), "i").unwrap();
199        let vendor = dir.path().join("vendor");
200        fs::create_dir(&vendor).unwrap();
201        fs::write(vendor.join("lib.md"), "v").unwrap();
202
203        let files = walk(dir.path(), &[]).unwrap();
204        let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
205        assert!(paths.contains(&"index.md"));
206        assert!(!paths.iter().any(|p| p.contains("vendor")));
207    }
208
209    #[cfg(unix)]
210    #[test]
211    fn symlink_escaping_root_has_no_bytes() {
212        let outer = TempDir::new().unwrap();
213        let root = outer.path().join("project");
214        fs::create_dir(&root).unwrap();
215        fs::write(root.join("index.md"), "i").unwrap();
216        fs::write(outer.path().join("secret.md"), "secret").unwrap();
217        std::os::unix::fs::symlink(outer.path().join("secret.md"), root.join("trap.md")).unwrap();
218
219        let files = walk(&root, &[]).unwrap();
220        let trap = files.iter().find(|f| f.path == "trap.md").unwrap();
221        assert!(
222            trap.bytes.is_none(),
223            "escaping symlink should carry no bytes"
224        );
225    }
226
227    #[cfg(unix)]
228    #[test]
229    fn symlink_to_directory_is_typed_symlink() {
230        // Indirection wins over target kind: a symlink pointing at a directory is
231        // a `Symlink`, not a `Dir`. The link's target resolves through the edge
232        // the builder emits, not the node's type.
233        let dir = TempDir::new().unwrap();
234        fs::create_dir(dir.path().join("real")).unwrap();
235        std::os::unix::fs::symlink(dir.path().join("real"), dir.path().join("alias")).unwrap();
236
237        let files = walk(dir.path(), &[]).unwrap();
238        let alias = files.iter().find(|f| f.path == "alias").unwrap();
239        assert_eq!(alias.kind, NodeKind::Symlink);
240        let real = files.iter().find(|f| f.path == "real").unwrap();
241        assert_eq!(real.kind, NodeKind::Dir);
242    }
243
244    #[cfg(unix)]
245    #[test]
246    fn symlink_to_file_carries_no_bytes() {
247        // A symlink is pure indirection: its node is never hashed. Content lives
248        // at the real path; the builder's edge carries the relationship.
249        let dir = TempDir::new().unwrap();
250        fs::write(dir.path().join("real.md"), "content").unwrap();
251        std::os::unix::fs::symlink(dir.path().join("real.md"), dir.path().join("alias.md"))
252            .unwrap();
253
254        let files = walk(dir.path(), &[]).unwrap();
255        let alias = files.iter().find(|f| f.path == "alias.md").unwrap();
256        assert_eq!(alias.kind, NodeKind::Symlink);
257        assert!(alias.bytes.is_none(), "symlink node must carry no bytes");
258        let real = files.iter().find(|f| f.path == "real.md").unwrap();
259        assert_eq!(real.bytes.as_deref(), Some(&b"content"[..]));
260    }
261
262    #[cfg(unix)]
263    #[test]
264    fn does_not_descend_into_symlinked_directory() {
265        // A symlinked directory is a leaf node, not a second copy of the subtree.
266        let dir = TempDir::new().unwrap();
267        fs::create_dir(dir.path().join("real")).unwrap();
268        fs::write(dir.path().join("real/child.md"), "c").unwrap();
269        std::os::unix::fs::symlink(dir.path().join("real"), dir.path().join("alias")).unwrap();
270
271        let files = walk(dir.path(), &[]).unwrap();
272        let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
273        assert!(paths.contains(&"alias"), "the symlink itself is a node");
274        assert!(
275            paths.contains(&"real/child.md"),
276            "real content appears once"
277        );
278        assert!(
279            !paths.contains(&"alias/child.md"),
280            "must not re-walk the target subtree through the symlink, got: {paths:?}"
281        );
282    }
283
284    #[cfg(unix)]
285    #[test]
286    fn does_not_leak_content_through_escaping_directory_symlink() {
287        // A symlink to a directory outside the root must not pull outside files
288        // into the graph as readable nodes.
289        let outer = TempDir::new().unwrap();
290        let root = outer.path().join("project");
291        fs::create_dir(&root).unwrap();
292        fs::create_dir(outer.path().join("secrets")).unwrap();
293        fs::write(outer.path().join("secrets/passwd.md"), "TOP SECRET").unwrap();
294        std::os::unix::fs::symlink(outer.path().join("secrets"), root.join("alias")).unwrap();
295
296        let files = walk(&root, &[]).unwrap();
297        assert!(
298            !files.iter().any(|f| f.path.contains("passwd")),
299            "outside content must not be walked through a symlink"
300        );
301    }
302}