1use std::fs;
7use std::path::Path;
8
9use anyhow::Result;
10use ignore::gitignore::{Gitignore, GitignoreBuilder};
11use ignore::Match;
12use substrate::{DirReader, TreeEntry};
13
14pub struct FsReader {
23 gitignores: Vec<Gitignore>,
26}
27
28impl FsReader {
29 pub fn new(root_path: &Path, exclude_names: &[String], follow_rules: &[String]) -> Self {
30 let mut gitignores = Vec::new();
31 if !follow_rules.is_empty() {
32 discover_follow_rules(root_path, exclude_names, follow_rules, &mut gitignores);
33 }
34 Self { gitignores }
35 }
36}
37
38impl substrate::DirReader for FsReader {
39 fn read_dir(&self, path: &Path) -> Result<Vec<substrate::DirEntry>> {
40 let mut entries = Vec::new();
41 for entry in fs::read_dir(path)? {
42 let entry = entry?;
43 let path = entry.path();
44 let name = entry.file_name().to_string_lossy().to_string();
45 let file_type = fs::symlink_metadata(&path)?.file_type();
46
47 if file_type.is_symlink() || (!file_type.is_file() && !file_type.is_dir()) {
51 continue;
52 }
53
54 entries.push(substrate::DirEntry {
55 name,
56 is_dir: file_type.is_dir(),
57 is_file: file_type.is_file(),
58 });
59 }
60 Ok(entries)
61 }
62
63 fn read_file(&self, path: &Path) -> Result<Vec<u8>> {
64 Ok(fs::read(path)?)
65 }
66
67 fn is_executable(&self, path: &Path) -> Result<bool> {
68 #[cfg(unix)]
69 {
70 use std::os::unix::fs::PermissionsExt;
71 let metadata = fs::metadata(path)?;
72 Ok(metadata.permissions().mode() & 0o111 != 0)
73 }
74
75 #[cfg(not(unix))]
76 {
77 let _ = path;
78 Ok(false)
79 }
80 }
81
82 fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
83 path_is_ignored(&self.gitignores, path, is_dir)
84 }
85
86 fn mtime_ms(&self, path: &Path) -> Result<Option<u64>> {
87 let meta = fs::metadata(path)?;
88 Ok(meta
89 .modified()
90 .ok()
91 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
92 .map(|d| d.as_millis() as u64))
93 }
94}
95
96fn path_is_ignored(gitignores: &[Gitignore], path: &Path, is_dir: bool) -> bool {
100 let mut ignored = false;
101 for gi in gitignores {
102 if path.starts_with(gi.path()) {
103 match gi.matched_path_or_any_parents(path, is_dir) {
104 Match::Ignore(_) => ignored = true,
105 Match::Whitelist(_) => ignored = false,
106 Match::None => {}
107 }
108 }
109 }
110 ignored
111}
112
113fn discover_follow_rules(
119 dir: &Path,
120 exclude_names: &[String],
121 follow_rules: &[String],
122 acc: &mut Vec<Gitignore>,
123) {
124 let mut builder = GitignoreBuilder::new(dir);
125 let mut found = false;
126 for rule_file in follow_rules {
127 let path = dir.join(rule_file);
128 if path.is_file() && builder.add(&path).is_none() {
129 found = true;
130 }
131 }
132 if found {
133 if let Ok(gi) = builder.build() {
134 acc.push(gi);
135 }
136 }
137
138 let read = match fs::read_dir(dir) {
139 Ok(read) => read,
140 Err(_) => return,
141 };
142 for entry in read.flatten() {
143 let name = entry.file_name().to_string_lossy().to_string();
144 if substrate::tree::should_exclude(&name, exclude_names) {
145 continue;
146 }
147 let path = entry.path();
148 let Ok(file_type) = fs::symlink_metadata(&path).map(|m| m.file_type()) else {
149 continue;
150 };
151 if !file_type.is_dir() || file_type.is_symlink() {
152 continue;
153 }
154 if path_is_ignored(acc, &path, true) {
155 continue;
156 }
157 discover_follow_rules(&path, exclude_names, follow_rules, acc);
158 }
159}
160
161pub fn walk_dir(
166 dir_path: &Path,
167 exclude_names: &[String],
168 follow_rules: &[String],
169) -> Result<Vec<TreeEntry>> {
170 let reader = FsReader::new(dir_path, exclude_names, follow_rules);
171 substrate::walk_dir(&reader, dir_path, exclude_names)
172}
173
174pub fn compute_tree_hash(dir_path: &Path, tree: &substrate::SporeTree) -> Result<String> {
176 let entries = walk_dir(dir_path, &tree.exclude_names, &tree.follow_rules)?;
177 tree.compute_hash(&entries)
178}
179
180pub fn check_no_symlinks(
185 dir_path: &Path,
186 exclude_names: &[String],
187 follow_rules: &[String],
188) -> Result<()> {
189 let reader = FsReader::new(dir_path, exclude_names, follow_rules);
190 check_no_symlinks_inner(&reader, dir_path, dir_path, exclude_names)
191}
192
193fn check_no_symlinks_inner(
194 reader: &FsReader,
195 root: &Path,
196 dir_path: &Path,
197 exclude_names: &[String],
198) -> Result<()> {
199 for entry in fs::read_dir(dir_path)? {
200 let entry = entry?;
201 let path = entry.path();
202 let name = entry.file_name().to_string_lossy().to_string();
203
204 if substrate::tree::should_exclude(&name, exclude_names) {
205 continue;
206 }
207 if reader.is_ignored(&path, false) {
208 continue;
209 }
210
211 let file_type = fs::symlink_metadata(&path)?.file_type();
212 if file_type.is_symlink() {
213 let target = fs::read_link(&path)
214 .map(|t| t.to_string_lossy().into_owned())
215 .unwrap_or_else(|_| "?".to_string());
216 let relative = path.strip_prefix(root).unwrap_or(&path);
217 anyhow::bail!(
218 "symlink found: {} → {}\n\
219 Symlinks are not included in spore content.\n \
220 To include the target content: cp -L \"{0}\" \"{0}.tmp\" && mv \"{0}.tmp\" \"{0}\"\n \
221 To exclude it: add \"{}\" to exclude_names",
222 relative.display(), target, name,
223 );
224 }
225 if file_type.is_dir() {
226 check_no_symlinks_inner(reader, root, &path, exclude_names)?;
227 }
228 }
229 Ok(())
230}
231
232#[cfg(test)]
233#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
234mod tests {
235 use super::*;
236
237 #[cfg(unix)]
238 #[test]
239 fn walk_dir_skips_symlink_entries() {
240 use std::os::unix::fs::symlink;
241
242 let temp = tempfile::tempdir().unwrap();
243 let root = temp.path();
244 let target = root.join("target.txt");
245 let regular = root.join("regular.txt");
246 let symlink_path = root.join("linked.txt");
247
248 std::fs::write(&target, "target").unwrap();
249 std::fs::write(®ular, "regular").unwrap();
250 symlink(&target, &symlink_path).unwrap();
251
252 let entries = walk_dir(root, &[], &[]).unwrap();
253 let flat = substrate::flatten_entries(&entries);
254 let names: Vec<String> = flat.into_iter().map(|(path, _, _)| path).collect();
255
256 assert!(names.contains(&"regular.txt".to_string()));
257 assert!(names.contains(&"target.txt".to_string()));
258 assert!(
259 !names.contains(&"linked.txt".to_string()),
260 "symlink entries must be skipped"
261 );
262 }
263
264 #[cfg(unix)]
265 #[test]
266 fn check_no_symlinks_catches_symlink() {
267 use std::os::unix::fs::symlink;
268
269 let temp = tempfile::tempdir().unwrap();
270 let root = temp.path();
271 std::fs::write(root.join("target.txt"), "target").unwrap();
272 symlink("target.txt", root.join("linked.txt")).unwrap();
273
274 let err = check_no_symlinks(root, &[], &[]).unwrap_err();
275 let msg = err.to_string();
276 assert!(
277 msg.contains("symlink found"),
278 "error should mention symlink: {}",
279 msg
280 );
281 assert!(
282 msg.contains("linked.txt"),
283 "error should name the file: {}",
284 msg
285 );
286 }
287
288 #[cfg(unix)]
289 #[test]
290 fn check_no_symlinks_respects_exclude_names() {
291 use std::os::unix::fs::symlink;
292
293 let temp = tempfile::tempdir().unwrap();
294 let root = temp.path();
295 std::fs::write(root.join("regular.txt"), "data").unwrap();
296 symlink("regular.txt", root.join("linked.txt")).unwrap();
297
298 assert!(check_no_symlinks(root, &["linked.txt".to_string()], &[]).is_ok());
300 }
301
302 #[cfg(unix)]
303 #[test]
304 fn check_no_symlinks_honors_nested_gitignore() {
305 use std::os::unix::fs::symlink;
306
307 let temp = tempfile::tempdir().unwrap();
308 let root = temp.path();
309 let sub = root.join("typescript");
313 std::fs::create_dir(&sub).unwrap();
314 std::fs::write(sub.join(".gitignore"), "node_modules/\n").unwrap();
315 let bin = sub.join("node_modules").join(".bin");
316 std::fs::create_dir_all(&bin).unwrap();
317 std::fs::write(bin.join("target"), "x").unwrap();
318 symlink("target", bin.join("tsx")).unwrap();
319
320 assert!(check_no_symlinks(root, &[], &[".gitignore".to_string()]).is_ok());
323 }
324
325 #[test]
326 fn walk_dir_honors_nested_gitignore() {
327 let temp = tempfile::tempdir().unwrap();
328 let root = temp.path();
329 let sub = root.join("pkg");
330 std::fs::create_dir(&sub).unwrap();
331 std::fs::write(sub.join(".gitignore"), "build/\n").unwrap();
332 std::fs::write(sub.join("keep.txt"), "keep").unwrap();
333 std::fs::create_dir(sub.join("build")).unwrap();
334 std::fs::write(sub.join("build").join("out.o"), "obj").unwrap();
335
336 let entries = walk_dir(root, &[], &[".gitignore".to_string()]).unwrap();
337 let names: Vec<String> = substrate::flatten_entries(&entries)
338 .into_iter()
339 .map(|(path, _, _)| path)
340 .collect();
341
342 assert!(
343 names.iter().any(|n| n.ends_with("keep.txt")),
344 "tracked sibling must survive: {names:?}"
345 );
346 assert!(
347 !names.iter().any(|n| n.contains("build")),
348 "nested-gitignored dir must be excluded: {names:?}"
349 );
350 }
351
352 #[test]
353 fn deeper_gitignore_overrides_shallower() {
354 let temp = tempfile::tempdir().unwrap();
355 let root = temp.path();
356 std::fs::write(root.join(".gitignore"), "*.log\n").unwrap();
359 let sub = root.join("keep");
360 std::fs::create_dir(&sub).unwrap();
361 std::fs::write(sub.join(".gitignore"), "!*.log\n").unwrap();
362 std::fs::write(root.join("root.log"), "r").unwrap();
363 std::fs::write(sub.join("kept.log"), "k").unwrap();
364
365 let entries = walk_dir(root, &[], &[".gitignore".to_string()]).unwrap();
366 let names: Vec<String> = substrate::flatten_entries(&entries)
367 .into_iter()
368 .map(|(path, _, _)| path)
369 .collect();
370
371 assert!(
372 !names.iter().any(|n| n.ends_with("root.log")),
373 "root-level *.log stays ignored: {names:?}"
374 );
375 assert!(
376 names.iter().any(|n| n.ends_with("kept.log")),
377 "deeper !*.log re-includes it: {names:?}"
378 );
379 }
380}