1use std::fs;
7use std::path::Path;
8
9use anyhow::Result;
10use ignore::gitignore::{Gitignore, GitignoreBuilder};
11use substrate::{DirReader, TreeEntry};
12
13pub struct FsReader {
15 gitignore: Option<Gitignore>,
16}
17
18impl FsReader {
19 pub fn new(root_path: &Path, follow_rules: &[String]) -> Self {
20 Self {
21 gitignore: build_follow_rules(root_path, follow_rules),
22 }
23 }
24}
25
26impl substrate::DirReader for FsReader {
27 fn read_dir(&self, path: &Path) -> Result<Vec<substrate::DirEntry>> {
28 let mut entries = Vec::new();
29 for entry in fs::read_dir(path)? {
30 let entry = entry?;
31 let path = entry.path();
32 let name = entry.file_name().to_string_lossy().to_string();
33 let file_type = fs::symlink_metadata(&path)?.file_type();
34
35 if file_type.is_symlink() || (!file_type.is_file() && !file_type.is_dir()) {
39 continue;
40 }
41
42 entries.push(substrate::DirEntry {
43 name,
44 is_dir: file_type.is_dir(),
45 is_file: file_type.is_file(),
46 });
47 }
48 Ok(entries)
49 }
50
51 fn read_file(&self, path: &Path) -> Result<Vec<u8>> {
52 Ok(fs::read(path)?)
53 }
54
55 fn is_executable(&self, path: &Path) -> Result<bool> {
56 #[cfg(unix)]
57 {
58 use std::os::unix::fs::PermissionsExt;
59 let metadata = fs::metadata(path)?;
60 Ok(metadata.permissions().mode() & 0o111 != 0)
61 }
62
63 #[cfg(not(unix))]
64 {
65 let _ = path;
66 Ok(false)
67 }
68 }
69
70 fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
71 match &self.gitignore {
72 Some(gi) => gi.matched_path_or_any_parents(path, is_dir).is_ignore(),
73 None => false,
74 }
75 }
76
77 fn mtime_ms(&self, path: &Path) -> Result<Option<u64>> {
78 let meta = fs::metadata(path)?;
79 Ok(meta
80 .modified()
81 .ok()
82 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
83 .map(|d| d.as_millis() as u64))
84 }
85}
86
87fn build_follow_rules(root_path: &Path, follow_rules: &[String]) -> Option<Gitignore> {
88 if follow_rules.is_empty() {
89 return None;
90 }
91
92 let mut builder = GitignoreBuilder::new(root_path);
93 let mut found_any = false;
94
95 for rule_file in follow_rules {
96 let path = root_path.join(rule_file);
97 if path.exists() && builder.add(&path).is_none() {
98 found_any = true;
99 }
100 }
101
102 if !found_any {
103 return None;
104 }
105
106 builder.build().ok()
107}
108
109pub fn walk_dir(
114 dir_path: &Path,
115 exclude_names: &[String],
116 follow_rules: &[String],
117) -> Result<Vec<TreeEntry>> {
118 let reader = FsReader::new(dir_path, follow_rules);
119 substrate::walk_dir(&reader, dir_path, exclude_names)
120}
121
122pub fn compute_tree_hash(dir_path: &Path, tree: &substrate::SporeTree) -> Result<String> {
124 let entries = walk_dir(dir_path, &tree.exclude_names, &tree.follow_rules)?;
125 tree.compute_hash(&entries)
126}
127
128pub fn check_no_symlinks(
133 dir_path: &Path,
134 exclude_names: &[String],
135 follow_rules: &[String],
136) -> Result<()> {
137 let reader = FsReader::new(dir_path, follow_rules);
138 check_no_symlinks_inner(&reader, dir_path, dir_path, exclude_names)
139}
140
141fn check_no_symlinks_inner(
142 reader: &FsReader,
143 root: &Path,
144 dir_path: &Path,
145 exclude_names: &[String],
146) -> Result<()> {
147 for entry in fs::read_dir(dir_path)? {
148 let entry = entry?;
149 let path = entry.path();
150 let name = entry.file_name().to_string_lossy().to_string();
151
152 if substrate::tree::should_exclude(&name, exclude_names) {
153 continue;
154 }
155 if reader.is_ignored(&path, false) {
156 continue;
157 }
158
159 let file_type = fs::symlink_metadata(&path)?.file_type();
160 if file_type.is_symlink() {
161 let target = fs::read_link(&path)
162 .map(|t| t.to_string_lossy().into_owned())
163 .unwrap_or_else(|_| "?".to_string());
164 let relative = path.strip_prefix(root).unwrap_or(&path);
165 anyhow::bail!(
166 "symlink found: {} → {}\n\
167 Symlinks are not included in spore content.\n \
168 To include the target content: cp -L \"{0}\" \"{0}.tmp\" && mv \"{0}.tmp\" \"{0}\"\n \
169 To exclude it: add \"{}\" to exclude_names",
170 relative.display(), target, name,
171 );
172 }
173 if file_type.is_dir() {
174 check_no_symlinks_inner(reader, root, &path, exclude_names)?;
175 }
176 }
177 Ok(())
178}
179
180#[cfg(test)]
181#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
182mod tests {
183 use super::*;
184
185 #[cfg(unix)]
186 #[test]
187 fn walk_dir_skips_symlink_entries() {
188 use std::os::unix::fs::symlink;
189
190 let temp = tempfile::tempdir().unwrap();
191 let root = temp.path();
192 let target = root.join("target.txt");
193 let regular = root.join("regular.txt");
194 let symlink_path = root.join("linked.txt");
195
196 std::fs::write(&target, "target").unwrap();
197 std::fs::write(®ular, "regular").unwrap();
198 symlink(&target, &symlink_path).unwrap();
199
200 let entries = walk_dir(root, &[], &[]).unwrap();
201 let flat = substrate::flatten_entries(&entries);
202 let names: Vec<String> = flat.into_iter().map(|(path, _, _)| path).collect();
203
204 assert!(names.contains(&"regular.txt".to_string()));
205 assert!(names.contains(&"target.txt".to_string()));
206 assert!(
207 !names.contains(&"linked.txt".to_string()),
208 "symlink entries must be skipped"
209 );
210 }
211
212 #[cfg(unix)]
213 #[test]
214 fn check_no_symlinks_catches_symlink() {
215 use std::os::unix::fs::symlink;
216
217 let temp = tempfile::tempdir().unwrap();
218 let root = temp.path();
219 std::fs::write(root.join("target.txt"), "target").unwrap();
220 symlink("target.txt", root.join("linked.txt")).unwrap();
221
222 let err = check_no_symlinks(root, &[], &[]).unwrap_err();
223 let msg = err.to_string();
224 assert!(
225 msg.contains("symlink found"),
226 "error should mention symlink: {}",
227 msg
228 );
229 assert!(
230 msg.contains("linked.txt"),
231 "error should name the file: {}",
232 msg
233 );
234 }
235
236 #[cfg(unix)]
237 #[test]
238 fn check_no_symlinks_respects_exclude_names() {
239 use std::os::unix::fs::symlink;
240
241 let temp = tempfile::tempdir().unwrap();
242 let root = temp.path();
243 std::fs::write(root.join("regular.txt"), "data").unwrap();
244 symlink("regular.txt", root.join("linked.txt")).unwrap();
245
246 assert!(check_no_symlinks(root, &["linked.txt".to_string()], &[]).is_ok());
248 }
249}