1use anyhow::Result;
5use globset::GlobSet;
6use ignore::WalkBuilder;
7use std::path::Path;
8
9use crate::config::compile_globs;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum NodeKind {
16 File,
17 Symlink,
18 Dir,
19}
20
21pub struct SourceFile {
24 pub path: String,
26 pub kind: NodeKind,
28 pub bytes: Option<Vec<u8>>,
31}
32
33pub 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 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 if relative.is_empty() {
70 continue;
71 }
72
73 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 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
103fn abs_lstat(root: &Path, relative: &str) -> Option<std::fs::Metadata> {
105 root.join(relative).symlink_metadata().ok()
106}
107
108fn 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 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 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 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 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 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}