fluidattacks_core_git/
fs.rs1use anyhow::Result;
2use ignore::gitignore::GitignoreBuilder;
3use std::fs;
4use std::path::Path;
5use walkdir::WalkDir;
6
7pub fn remove_symlinks(dir: &Path) -> Result<()> {
8 for entry in WalkDir::new(dir)
9 .min_depth(1)
10 .into_iter()
11 .filter_map(Result::ok)
12 {
13 if entry.file_type().is_symlink() {
14 fs::remove_file(entry.path())?;
15 }
16 }
17
18 Ok(())
19}
20
21pub fn delete_gitignored_files(repo_path: &Path, patterns: &[String]) -> Result<()> {
22 let mut builder = GitignoreBuilder::new(repo_path);
23 for pattern in patterns {
24 if let Err(error) = builder.add_line(None, pattern) {
25 tracing::warn!(pattern, error = %error, "skipping an exclusion git cannot compile");
26 }
27 }
28 let gitignore = builder.build()?;
29
30 for entry in WalkDir::new(repo_path) {
31 let entry = entry?;
32 let path = entry.path();
33
34 let Ok(rel) = path.strip_prefix(repo_path) else {
35 continue;
36 };
37
38 let rel_str = rel.to_string_lossy();
39
40 if rel_str == ".git" || rel_str.starts_with(".git/") || rel_str.starts_with(".git\\") {
41 continue;
42 }
43
44 if rel_str.is_empty() {
45 continue;
46 }
47
48 if !entry.file_type().is_dir()
49 && gitignore
50 .matched_path_or_any_parents(rel, false)
51 .is_ignore()
52 {
53 let _ = fs::remove_file(path);
54 }
55 }
56
57 remove_empty_dirs(repo_path)?;
58 Ok(())
59}
60
61pub fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
62 fs::create_dir_all(dst)?;
63 for entry in fs::read_dir(src)? {
64 let entry = entry?;
65 let ty = entry.file_type()?;
66 let dst_path = dst.join(entry.file_name());
67 if ty.is_dir() {
68 copy_dir_all(&entry.path(), &dst_path)?;
69 } else {
70 fs::copy(entry.path(), &dst_path)?;
71 copy_modified(&entry.path(), &dst_path);
72 }
73 }
74 Ok(())
75}
76
77fn copy_modified(src: &Path, dst: &Path) {
78 let Ok(modified) = fs::metadata(src).and_then(|meta| meta.modified()) else {
79 return;
80 };
81 if let Ok(file) = fs::File::open(dst) {
82 let _ = file.set_modified(modified);
83 }
84}
85
86fn remove_empty_dirs(root: &Path) -> Result<()> {
87 let mut dirs: Vec<_> = Vec::new();
88
89 for entry in WalkDir::new(root) {
90 let entry = entry?;
91 let path = entry.path();
92
93 let Ok(rel) = path.strip_prefix(root) else {
94 continue;
95 };
96
97 let rel_str = rel.to_string_lossy();
98
99 if rel_str == ".git" || rel_str.starts_with(".git/") || rel_str.starts_with(".git\\") {
100 continue;
101 }
102
103 if entry.file_type().is_dir() && path != root {
104 dirs.push(path.to_path_buf());
105 }
106 }
107
108 dirs.sort();
109 dirs.reverse();
110
111 for dir in dirs {
112 if let Ok(entries) = fs::read_dir(&dir) {
113 if entries.count() == 0 {
114 let _ = fs::remove_dir(&dir);
115 }
116 }
117 }
118
119 Ok(())
120}
121
122#[cfg(test)]
123mod tests {
124 use super::{copy_dir_all, delete_gitignored_files, remove_symlinks};
125 use std::time::{Duration, SystemTime};
126
127 #[test]
128 fn a_directory_pattern_deletes_what_is_under_it() {
129 let dir = tempfile::tempdir().unwrap();
130 let repo = dir.path();
131 std::fs::create_dir_all(repo.join("docs/sub")).unwrap();
132 std::fs::create_dir_all(repo.join(".git")).unwrap();
133 std::fs::write(repo.join("docs/a.md"), b"out").unwrap();
134 std::fs::write(repo.join("docs/sub/b.md"), b"out").unwrap();
135 std::fs::write(repo.join("keep.py"), b"in").unwrap();
136 std::fs::write(repo.join(".git/config"), b"[core]").unwrap();
137
138 delete_gitignored_files(repo, &["docs".to_owned()]).unwrap();
139
140 assert!(!repo.join("docs/a.md").exists());
141 assert!(!repo.join("docs/sub/b.md").exists());
142 assert!(repo.join("keep.py").exists());
143 assert!(repo.join(".git/config").exists());
144 }
145
146 #[test]
147 fn an_exclusion_git_cannot_compile_does_not_disable_the_others() {
148 let dir = tempfile::tempdir().unwrap();
149 let repo = dir.path();
150 std::fs::create_dir_all(repo.join("docs")).unwrap();
151 std::fs::write(repo.join("docs/a.md"), b"out").unwrap();
152 std::fs::write(repo.join("keep.py"), b"in").unwrap();
153
154 delete_gitignored_files(repo, &["docs".to_owned(), "src/{main".to_owned()]).unwrap();
155
156 assert!(!repo.join("docs/a.md").exists());
157 assert!(repo.join("keep.py").exists());
158 }
159
160 #[test]
161 fn a_copied_tree_keeps_the_mode_and_the_modification_time() {
162 let dir = tempfile::tempdir().unwrap();
163 let src = dir.path().join("src/objects");
164 std::fs::create_dir_all(&src).unwrap();
165 let object = src.join("a1b2c3");
166 std::fs::write(&object, b"blob").unwrap();
167
168 let modified = SystemTime::UNIX_EPOCH
169 .checked_add(Duration::from_secs(1_700_000_000))
170 .unwrap();
171 std::fs::File::open(&object)
172 .unwrap()
173 .set_modified(modified)
174 .unwrap();
175 #[cfg(unix)]
176 {
177 use std::os::unix::fs::PermissionsExt;
178 std::fs::set_permissions(&object, std::fs::Permissions::from_mode(0o444)).unwrap();
179 }
180
181 let dst = dir.path().join("dst");
182 copy_dir_all(&dir.path().join("src"), &dst).unwrap();
183
184 let copied = dst.join("objects/a1b2c3");
185 let meta = std::fs::metadata(&copied).unwrap();
186 assert_eq!(meta.modified().unwrap(), modified);
187 #[cfg(unix)]
188 {
189 use std::os::unix::fs::PermissionsExt;
190 assert_eq!(meta.permissions().mode() & 0o777, 0o444);
191 }
192 }
193
194 #[test]
195 #[cfg(unix)]
196 fn an_unreadable_directory_does_not_stop_the_removal() {
197 use std::os::unix::fs::PermissionsExt;
198
199 let dir = tempfile::tempdir().unwrap();
200 let target = dir.path().join("target.txt");
201 std::fs::write(&target, b"content").unwrap();
202
203 let blocked = dir.path().join("b_blocked");
204 std::fs::create_dir(&blocked).unwrap();
205 let late = dir.path().join("c_late.lnk");
206 std::os::unix::fs::symlink(&target, &late).unwrap();
207 std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o000)).unwrap();
208
209 remove_symlinks(dir.path()).unwrap();
210
211 std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o755)).unwrap();
212 assert!(!late.is_symlink());
213 assert!(target.exists());
214 }
215
216 #[test]
217 #[cfg(unix)]
218 fn a_symlink_that_cannot_be_removed_is_reported() {
219 use std::os::unix::fs::PermissionsExt;
220
221 let dir = tempfile::tempdir().unwrap();
222 let sealed = dir.path().join("sealed");
223 std::fs::create_dir(&sealed).unwrap();
224 let link = sealed.join("link.lnk");
225 std::os::unix::fs::symlink(dir.path().join("target.txt"), &link).unwrap();
226 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o500)).unwrap();
227
228 if std::fs::write(sealed.join("probe"), b"").is_ok() {
231 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
232 return;
233 }
234
235 let result = remove_symlinks(dir.path());
236
237 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
238 assert!(result.is_err());
239 assert!(link.is_symlink());
240 }
241
242 #[test]
243 #[cfg(unix)]
244 fn the_directory_itself_is_never_removed() {
245 let dir = tempfile::tempdir().unwrap();
246 let real = dir.path().join("real");
247 std::fs::create_dir(&real).unwrap();
248 let alias = dir.path().join("alias");
249 std::os::unix::fs::symlink(&real, &alias).unwrap();
250
251 remove_symlinks(&alias).unwrap();
252
253 assert!(alias.is_symlink());
254 }
255}