1use anyhow::Result;
5use globset::GlobSet;
6use ignore::WalkBuilder;
7use std::path::Path;
8
9use crate::config::compile_globs;
10
11const VCS_DIRS: [&str; 4] = [".git", ".hg", ".svn", ".jj"];
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum NodeKind {
23 File,
24 Symlink,
25 Dir,
26}
27
28pub struct SourceFile {
31 pub path: String,
33 pub kind: NodeKind,
35 pub bytes: Option<Vec<u8>>,
38}
39
40pub fn walk(root: &Path, ignore: &[String]) -> Result<Vec<SourceFile>> {
61 let ignore_set = compile_globs(ignore)?;
62
63 let mut files = Vec::new();
64
65 let walker = WalkBuilder::new(root)
71 .follow_links(false)
72 .hidden(false)
73 .parents(false)
74 .git_global(false)
75 .git_exclude(false)
76 .filter_entry(|entry| {
77 entry
82 .file_name()
83 .to_str()
84 .is_none_or(|name| !VCS_DIRS.contains(&name))
85 })
86 .build();
87
88 for entry in walker {
89 let entry = entry?;
90 let ft = entry.file_type();
91 if !ft.is_some_and(|t| t.is_file() || t.is_dir() || t.is_symlink()) {
94 continue;
95 }
96
97 let relative = entry
98 .path()
99 .strip_prefix(root)
100 .expect("path should be under root")
101 .to_string_lossy()
102 .replace('\\', "/");
103
104 if relative.is_empty() {
106 continue;
107 }
108
109 let kind = match abs_lstat(root, &relative) {
112 Some(m) if m.file_type().is_symlink() => NodeKind::Symlink,
113 Some(m) if m.is_dir() => NodeKind::Dir,
114 _ => NodeKind::File,
115 };
116
117 if is_ignored(&ignore_set, &relative, kind) {
118 continue;
119 }
120
121 let bytes = match kind {
124 NodeKind::File => std::fs::read(root.join(&relative)).ok(),
125 NodeKind::Symlink | NodeKind::Dir => None,
126 };
127
128 files.push(SourceFile {
129 path: relative,
130 kind,
131 bytes,
132 });
133 }
134
135 files.sort_by(|a, b| a.path.cmp(&b.path));
136 Ok(files)
137}
138
139fn abs_lstat(root: &Path, relative: &str) -> Option<std::fs::Metadata> {
141 root.join(relative).symlink_metadata().ok()
142}
143
144fn is_ignored(ignore_set: &Option<GlobSet>, relative: &str, kind: NodeKind) -> bool {
149 let Some(set) = ignore_set else {
150 return false;
151 };
152 set.is_match(relative) || (kind == NodeKind::Dir && set.is_match(format!("{relative}/")))
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use std::fs;
159 use tempfile::TempDir;
160
161 #[test]
162 fn walks_all_files_sorted() {
163 let dir = TempDir::new().unwrap();
164 fs::write(dir.path().join("b.md"), "b").unwrap();
165 fs::write(dir.path().join("a.md"), "a").unwrap();
166 fs::write(dir.path().join("notes.txt"), "n").unwrap();
167
168 let files = walk(dir.path(), &[]).unwrap();
169 let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
170 assert_eq!(paths, vec!["a.md", "b.md", "notes.txt"]);
171 assert_eq!(files[0].bytes.as_deref(), Some(&b"a"[..]));
172 }
173
174 #[test]
175 fn respects_ignore_globs() {
176 let dir = TempDir::new().unwrap();
177 fs::write(dir.path().join("keep.md"), "k").unwrap();
178 let sub = dir.path().join("target");
179 fs::create_dir(&sub).unwrap();
180 fs::write(sub.join("build.md"), "b").unwrap();
181
182 let files = walk(dir.path(), &["target/**".to_string()]).unwrap();
183 let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
184 assert_eq!(paths, vec!["keep.md"]);
187 }
188
189 #[test]
190 fn yields_directory_nodes() {
191 let dir = TempDir::new().unwrap();
192 fs::create_dir(dir.path().join("guides")).unwrap();
193 fs::write(dir.path().join("guides/intro.md"), "i").unwrap();
194
195 let files = walk(dir.path(), &[]).unwrap();
196 let guides = files.iter().find(|f| f.path == "guides").unwrap();
197 assert_eq!(guides.kind, NodeKind::Dir);
198 assert!(guides.bytes.is_none(), "directories carry no content");
199
200 let intro = files.iter().find(|f| f.path == "guides/intro.md").unwrap();
201 assert_eq!(intro.kind, NodeKind::File);
202 }
203
204 #[test]
205 fn includes_empty_directories() {
206 let dir = TempDir::new().unwrap();
207 fs::create_dir(dir.path().join("empty")).unwrap();
208
209 let files = walk(dir.path(), &[]).unwrap();
210 assert!(
211 files
212 .iter()
213 .any(|f| f.path == "empty" && f.kind == NodeKind::Dir)
214 );
215 }
216
217 #[test]
218 fn root_is_not_a_node() {
219 let dir = TempDir::new().unwrap();
220 fs::write(dir.path().join("a.md"), "a").unwrap();
221
222 let files = walk(dir.path(), &[]).unwrap();
223 assert!(
224 !files.iter().any(|f| f.path.is_empty()),
225 "the graph root must not appear as a node"
226 );
227 }
228
229 #[test]
230 fn respects_gitignore() {
231 let dir = TempDir::new().unwrap();
232 fs::create_dir(dir.path().join(".git")).unwrap();
233 fs::write(dir.path().join(".gitignore"), "vendor/\n").unwrap();
234 fs::write(dir.path().join("index.md"), "i").unwrap();
235 let vendor = dir.path().join("vendor");
236 fs::create_dir(&vendor).unwrap();
237 fs::write(vendor.join("lib.md"), "v").unwrap();
238
239 let files = walk(dir.path(), &[]).unwrap();
240 let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
241 assert!(paths.contains(&"index.md"));
242 assert!(!paths.iter().any(|p| p.contains("vendor")));
243 }
244
245 #[test]
246 fn ignore_files_above_root_do_not_prune_the_walk() {
247 let outer = TempDir::new().unwrap();
251 fs::create_dir(outer.path().join(".git")).unwrap();
252 fs::write(outer.path().join(".gitignore"), "keep.md\n").unwrap();
253 let root = outer.path().join("project");
254 fs::create_dir(&root).unwrap();
255 fs::write(root.join("keep.md"), "k").unwrap();
256
257 let files = walk(&root, &[]).unwrap();
258 assert!(
259 files.iter().any(|f| f.path == "keep.md"),
260 "an ignore rule above the root must not prune the walk"
261 );
262 }
263
264 #[test]
265 fn dot_dirs_are_walked_but_vcs_dirs_are_pruned() {
266 let dir = TempDir::new().unwrap();
267 fs::create_dir(dir.path().join(".git")).unwrap();
269 fs::write(dir.path().join(".git").join("HEAD"), "ref: x").unwrap();
270 fs::create_dir(dir.path().join(".github")).unwrap();
272 fs::write(dir.path().join(".github").join("ci.yml"), "y").unwrap();
273
274 let files = walk(dir.path(), &[]).unwrap();
275 let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
276
277 assert!(
278 !paths.iter().any(|p| p.starts_with(".git/") || *p == ".git"),
279 "VCS metadata must be pruned, got: {paths:?}"
280 );
281 assert!(
282 paths.contains(&".github") && paths.contains(&".github/ci.yml"),
283 "ordinary dot-directories must be walked, got: {paths:?}"
284 );
285 }
286
287 #[cfg(unix)]
288 #[test]
289 fn symlink_escaping_root_has_no_bytes() {
290 let outer = TempDir::new().unwrap();
291 let root = outer.path().join("project");
292 fs::create_dir(&root).unwrap();
293 fs::write(root.join("index.md"), "i").unwrap();
294 fs::write(outer.path().join("secret.md"), "secret").unwrap();
295 std::os::unix::fs::symlink(outer.path().join("secret.md"), root.join("trap.md")).unwrap();
296
297 let files = walk(&root, &[]).unwrap();
298 let trap = files.iter().find(|f| f.path == "trap.md").unwrap();
299 assert!(
300 trap.bytes.is_none(),
301 "escaping symlink should carry no bytes"
302 );
303 }
304
305 #[cfg(unix)]
306 #[test]
307 fn symlink_to_directory_is_typed_symlink() {
308 let dir = TempDir::new().unwrap();
312 fs::create_dir(dir.path().join("real")).unwrap();
313 std::os::unix::fs::symlink(dir.path().join("real"), dir.path().join("alias")).unwrap();
314
315 let files = walk(dir.path(), &[]).unwrap();
316 let alias = files.iter().find(|f| f.path == "alias").unwrap();
317 assert_eq!(alias.kind, NodeKind::Symlink);
318 let real = files.iter().find(|f| f.path == "real").unwrap();
319 assert_eq!(real.kind, NodeKind::Dir);
320 }
321
322 #[cfg(unix)]
323 #[test]
324 fn symlink_to_file_carries_no_bytes() {
325 let dir = TempDir::new().unwrap();
328 fs::write(dir.path().join("real.md"), "content").unwrap();
329 std::os::unix::fs::symlink(dir.path().join("real.md"), dir.path().join("alias.md"))
330 .unwrap();
331
332 let files = walk(dir.path(), &[]).unwrap();
333 let alias = files.iter().find(|f| f.path == "alias.md").unwrap();
334 assert_eq!(alias.kind, NodeKind::Symlink);
335 assert!(alias.bytes.is_none(), "symlink node must carry no bytes");
336 let real = files.iter().find(|f| f.path == "real.md").unwrap();
337 assert_eq!(real.bytes.as_deref(), Some(&b"content"[..]));
338 }
339
340 #[cfg(unix)]
341 #[test]
342 fn does_not_descend_into_symlinked_directory() {
343 let dir = TempDir::new().unwrap();
345 fs::create_dir(dir.path().join("real")).unwrap();
346 fs::write(dir.path().join("real/child.md"), "c").unwrap();
347 std::os::unix::fs::symlink(dir.path().join("real"), dir.path().join("alias")).unwrap();
348
349 let files = walk(dir.path(), &[]).unwrap();
350 let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
351 assert!(paths.contains(&"alias"), "the symlink itself is a node");
352 assert!(
353 paths.contains(&"real/child.md"),
354 "real content appears once"
355 );
356 assert!(
357 !paths.contains(&"alias/child.md"),
358 "must not re-walk the target subtree through the symlink, got: {paths:?}"
359 );
360 }
361
362 #[cfg(unix)]
363 #[test]
364 fn does_not_leak_content_through_escaping_directory_symlink() {
365 let outer = TempDir::new().unwrap();
368 let root = outer.path().join("project");
369 fs::create_dir(&root).unwrap();
370 fs::create_dir(outer.path().join("secrets")).unwrap();
371 fs::write(outer.path().join("secrets/passwd.md"), "TOP SECRET").unwrap();
372 std::os::unix::fs::symlink(outer.path().join("secrets"), root.join("alias")).unwrap();
373
374 let files = walk(&root, &[]).unwrap();
375 assert!(
376 !files.iter().any(|f| f.path.contains("passwd")),
377 "outside content must not be walked through a symlink"
378 );
379 }
380}