fluidattacks_core_git/
fs.rs1use anyhow::{bail, Context, Result};
2use std::ffi::OsString;
3use std::fs;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7use tempfile::NamedTempFile;
8use walkdir::WalkDir;
9
10use crate::cli::path_from_bytes;
11
12pub fn remove_symlinks(dir: &Path) -> Result<()> {
13 for entry in WalkDir::new(dir)
14 .min_depth(1)
15 .into_iter()
16 .filter_map(Result::ok)
17 {
18 if entry.file_type().is_symlink() {
19 fs::remove_file(entry.path())?;
20 }
21 }
22
23 Ok(())
24}
25
26pub fn delete_gitignored_files(repo_path: &Path, patterns: &[String]) -> Result<()> {
27 if patterns.is_empty() {
29 return Ok(());
30 }
31 for file in excluded_files(repo_path, patterns)? {
32 let _ = fs::remove_file(repo_path.join(file));
33 }
34
35 remove_empty_dirs(repo_path)?;
36 Ok(())
37}
38
39fn excluded_files(repo_path: &Path, patterns: &[String]) -> Result<Vec<PathBuf>> {
40 let exclusions = exclusions_file(patterns)?;
41 let mut exclude_from = OsString::from("--exclude-from=");
42 exclude_from.push(exclusions.path());
43
44 let output = Command::new("git")
45 .arg("-C")
46 .arg(repo_path)
47 .args(["-c", "core.ignorecase=false"])
49 .args(["ls-files", "-z", "--cached", "--others", "--ignored"])
50 .arg(exclude_from)
51 .output()
52 .context("running git ls-files")?;
53 if !output.status.success() {
54 bail!(
55 "git ls-files failed: {}",
56 String::from_utf8_lossy(&output.stderr)
57 );
58 }
59
60 Ok(output
61 .stdout
62 .split(|byte| *byte == 0)
63 .filter(|file| !file.is_empty())
64 .map(path_from_bytes)
65 .collect())
66}
67
68fn exclusions_file(patterns: &[String]) -> Result<NamedTempFile> {
71 let mut file = NamedTempFile::new().context("creating the exclusions file")?;
72 for pattern in patterns.iter().filter(|pattern| !pattern.contains('\n')) {
74 writeln!(file, "{pattern}").context("writing the exclusions file")?;
75 }
76 Ok(file)
77}
78
79pub fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
80 fs::create_dir_all(dst)?;
81 for entry in fs::read_dir(src)? {
82 let entry = entry?;
83 let ty = entry.file_type()?;
84 let dst_path = dst.join(entry.file_name());
85 if ty.is_dir() {
86 copy_dir_all(&entry.path(), &dst_path)?;
87 } else {
88 fs::copy(entry.path(), &dst_path)?;
89 copy_modified(&entry.path(), &dst_path);
90 }
91 }
92 Ok(())
93}
94
95fn copy_modified(src: &Path, dst: &Path) {
96 let Ok(modified) = fs::metadata(src).and_then(|meta| meta.modified()) else {
97 return;
98 };
99 if let Ok(file) = fs::File::open(dst) {
100 let _ = file.set_modified(modified);
101 }
102}
103
104fn remove_empty_dirs(root: &Path) -> Result<()> {
105 let mut dirs: Vec<_> = Vec::new();
106
107 for entry in WalkDir::new(root) {
108 let entry = entry?;
109 let path = entry.path();
110
111 let Ok(rel) = path.strip_prefix(root) else {
112 continue;
113 };
114
115 let rel_str = rel.to_string_lossy();
116
117 if rel_str == ".git" || rel_str.starts_with(".git/") || rel_str.starts_with(".git\\") {
118 continue;
119 }
120
121 if entry.file_type().is_dir() && path != root {
122 dirs.push(path.to_path_buf());
123 }
124 }
125
126 dirs.sort();
127 dirs.reverse();
128
129 for dir in dirs {
130 if let Ok(entries) = fs::read_dir(&dir) {
131 if entries.count() == 0 {
132 let _ = fs::remove_dir(&dir);
133 }
134 }
135 }
136
137 Ok(())
138}
139
140#[cfg(test)]
141mod tests {
142 use super::{copy_dir_all, delete_gitignored_files, remove_symlinks};
143 use std::time::{Duration, SystemTime};
144
145 fn indexed_repo(files: &[&str]) -> tempfile::TempDir {
147 let dir = tempfile::tempdir().unwrap();
148 for file in files {
149 let path = dir.path().join(file);
150 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
151 std::fs::write(path, b"").unwrap();
152 }
153 for args in [&["init", "-q"][..], &["add", "-A"]] {
154 let status = std::process::Command::new("git")
155 .arg("-C")
156 .arg(dir.path())
157 .args(args)
158 .status()
159 .unwrap();
160 assert!(status.success());
161 }
162 dir
163 }
164
165 fn exclusions(patterns: &[&str]) -> Vec<String> {
166 patterns
167 .iter()
168 .map(|pattern| (*pattern).to_owned())
169 .collect()
170 }
171
172 #[test]
173 fn a_directory_pattern_deletes_what_is_under_it() {
174 let dir = indexed_repo(&["docs/a.md", "docs/sub/b.md", "keep.py"]);
175 let repo = dir.path();
176
177 delete_gitignored_files(repo, &exclusions(&["docs"])).unwrap();
178
179 assert!(!repo.join("docs").exists());
180 assert!(repo.join("keep.py").exists());
181 assert!(repo.join(".git/config").exists());
182 }
183
184 #[test]
185 fn no_exclusions_keep_every_file() {
186 let dir = indexed_repo(&["keep.py"]);
187
188 delete_gitignored_files(dir.path(), &[]).unwrap();
189
190 assert!(dir.path().join("keep.py").exists());
191 }
192
193 #[test]
194 fn an_exclusion_that_matches_nothing_does_not_disable_the_others() {
195 let dir = indexed_repo(&["docs/a.md", "keep.py"]);
196 let repo = dir.path();
197
198 delete_gitignored_files(repo, &exclusions(&["docs", "[z-a]"])).unwrap();
199
200 assert!(!repo.join("docs/a.md").exists());
201 assert!(repo.join("keep.py").exists());
202 }
203
204 #[test]
205 fn an_unclosed_bracket_matches_nothing() {
206 let dir = indexed_repo(&["[main", "]"]);
207 let repo = dir.path();
208
209 delete_gitignored_files(repo, &exclusions(&["[main", "[]]"])).unwrap();
210
211 assert!(repo.join("[main").exists());
212 assert!(!repo.join("]").exists());
213 }
214
215 #[test]
216 fn braces_match_literally() {
217 let dir = indexed_repo(&["a/x", "{a,b}/x", "a}b", "cq"]);
218 let repo = dir.path();
219
220 delete_gitignored_files(repo, &exclusions(&["{a,b}/x", "a}b", "c[a-{]"])).unwrap();
221
222 assert!(repo.join("a/x").exists());
223 assert!(!repo.join("{a,b}/x").exists());
224 assert!(!repo.join("a}b").exists());
225 assert!(!repo.join("cq").exists());
226 }
227
228 #[test]
229 fn a_backslash_takes_the_next_character_literally() {
230 let dir = indexed_repo(&["a[{b}", "{a}", "ab"]);
231 let repo = dir.path();
232
233 delete_gitignored_files(repo, &exclusions(&[r"a\[{b}", r"\{a}"])).unwrap();
234
235 assert!(!repo.join("a[{b}").exists());
236 assert!(!repo.join("{a}").exists());
237 assert!(repo.join("ab").exists());
238 }
239
240 #[test]
241 fn a_named_class_matches_its_characters() {
242 let dir = indexed_repo(&["f5", "fx"]);
243 let repo = dir.path();
244
245 delete_gitignored_files(repo, &exclusions(&["f[[:digit:]]"])).unwrap();
246
247 assert!(!repo.join("f5").exists());
248 assert!(repo.join("fx").exists());
249 }
250
251 #[test]
252 fn an_exclusion_keeps_its_case_in_a_case_insensitive_clone() {
253 let dir = indexed_repo(&["README.md"]);
254 let repo = dir.path();
255 let status = std::process::Command::new("git")
256 .arg("-C")
257 .arg(repo)
258 .args(["config", "core.ignorecase", "true"])
259 .status()
260 .unwrap();
261 assert!(status.success());
262
263 delete_gitignored_files(repo, &exclusions(&["readme.md"])).unwrap();
264
265 assert!(repo.join("README.md").exists());
266 }
267
268 #[test]
269 fn an_exclusion_reads_as_a_gitignore_line() {
270 let dir = indexed_repo(&["#a", "b", "c"]);
271 let repo = dir.path();
272
273 delete_gitignored_files(repo, &exclusions(&["#a", "b ", "c"])).unwrap();
274
275 assert!(repo.join("#a").exists());
276 assert!(!repo.join("b").exists());
277 assert!(!repo.join("c").exists());
278 }
279
280 #[test]
281 fn an_untracked_file_is_deleted_too() {
282 let dir = indexed_repo(&["docs/a.md"]);
283 let repo = dir.path();
284 std::fs::write(repo.join("docs/new.md"), b"").unwrap();
285
286 delete_gitignored_files(repo, &exclusions(&["docs"])).unwrap();
287
288 assert!(!repo.join("docs").exists());
289 }
290
291 #[test]
292 fn an_exclusion_with_a_line_break_adds_no_rules() {
293 let dir = indexed_repo(&["a", "b"]);
294 let repo = dir.path();
295
296 delete_gitignored_files(repo, &exclusions(&["a\nb"])).unwrap();
297
298 assert!(repo.join("a").exists());
299 assert!(repo.join("b").exists());
300 }
301
302 #[test]
303 fn a_copied_tree_keeps_the_mode_and_the_modification_time() {
304 let dir = tempfile::tempdir().unwrap();
305 let src = dir.path().join("src/objects");
306 std::fs::create_dir_all(&src).unwrap();
307 let object = src.join("a1b2c3");
308 std::fs::write(&object, b"blob").unwrap();
309
310 let modified = SystemTime::UNIX_EPOCH
311 .checked_add(Duration::from_secs(1_700_000_000))
312 .unwrap();
313 std::fs::File::open(&object)
314 .unwrap()
315 .set_modified(modified)
316 .unwrap();
317 #[cfg(unix)]
318 {
319 use std::os::unix::fs::PermissionsExt;
320 std::fs::set_permissions(&object, std::fs::Permissions::from_mode(0o444)).unwrap();
321 }
322
323 let dst = dir.path().join("dst");
324 copy_dir_all(&dir.path().join("src"), &dst).unwrap();
325
326 let copied = dst.join("objects/a1b2c3");
327 let meta = std::fs::metadata(&copied).unwrap();
328 assert_eq!(meta.modified().unwrap(), modified);
329 #[cfg(unix)]
330 {
331 use std::os::unix::fs::PermissionsExt;
332 assert_eq!(meta.permissions().mode() & 0o777, 0o444);
333 }
334 }
335
336 #[test]
337 #[cfg(unix)]
338 fn an_unreadable_directory_does_not_stop_the_removal() {
339 use std::os::unix::fs::PermissionsExt;
340
341 let dir = tempfile::tempdir().unwrap();
342 let target = dir.path().join("target.txt");
343 std::fs::write(&target, b"content").unwrap();
344
345 let blocked = dir.path().join("b_blocked");
346 std::fs::create_dir(&blocked).unwrap();
347 let late = dir.path().join("c_late.lnk");
348 std::os::unix::fs::symlink(&target, &late).unwrap();
349 std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o000)).unwrap();
350
351 remove_symlinks(dir.path()).unwrap();
352
353 std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o755)).unwrap();
354 assert!(!late.is_symlink());
355 assert!(target.exists());
356 }
357
358 #[test]
359 #[cfg(unix)]
360 fn a_symlink_that_cannot_be_removed_is_reported() {
361 use std::os::unix::fs::PermissionsExt;
362
363 let dir = tempfile::tempdir().unwrap();
364 let sealed = dir.path().join("sealed");
365 std::fs::create_dir(&sealed).unwrap();
366 let link = sealed.join("link.lnk");
367 std::os::unix::fs::symlink(dir.path().join("target.txt"), &link).unwrap();
368 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o500)).unwrap();
369
370 if std::fs::write(sealed.join("probe"), b"").is_ok() {
373 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
374 return;
375 }
376
377 let result = remove_symlinks(dir.path());
378
379 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
380 assert!(result.is_err());
381 assert!(link.is_symlink());
382 }
383
384 #[test]
385 #[cfg(unix)]
386 fn the_directory_itself_is_never_removed() {
387 let dir = tempfile::tempdir().unwrap();
388 let real = dir.path().join("real");
389 std::fs::create_dir(&real).unwrap();
390 let alias = dir.path().join("alias");
391 std::os::unix::fs::symlink(&real, &alias).unwrap();
392
393 remove_symlinks(&alias).unwrap();
394
395 assert!(alias.is_symlink());
396 }
397}