Skip to main content

fluidattacks_core_git/
fs.rs

1use std::env;
2use std::ffi::OsString;
3use std::fs;
4use std::io::{self, Write};
5use std::path::{Path, PathBuf};
6use std::process::Command;
7use tempfile::{NamedTempFile, TempDir};
8use walkdir::WalkDir;
9
10use crate::call;
11use crate::cli::{path_from_bytes, path_to_bytes};
12use crate::errors::{FileError, PruneError};
13
14const EMPTY_BLOB: &str = "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391";
15
16pub fn remove_symlinks(dir: &Path) -> Result<(), FileError> {
17    for entry in WalkDir::new(dir)
18        .min_depth(1)
19        .into_iter()
20        .filter_map(Result::ok)
21    {
22        if entry.file_type().is_symlink() {
23            remove_if_present(entry.path(), fs::remove_file)?;
24        }
25    }
26
27    Ok(())
28}
29
30pub fn delete_gitignored_files(repo_path: &Path, patterns: &[String]) -> Result<(), PruneError> {
31    // Git refuses `--ignored` without a pattern, and none excludes nothing.
32    if patterns.is_empty() {
33        return Ok(());
34    }
35    // Git reports a missing tree as a failed command, without the errno.
36    fs::read_dir(repo_path).map_err(file_error("reading", repo_path))?;
37    let repo_path = &std::path::absolute(repo_path).map_err(file_error("resolving", repo_path))?;
38    for file in excluded_files(repo_path, patterns)? {
39        remove_if_present(&repo_path.join(file), fs::remove_file)?;
40    }
41
42    remove_empty_dirs(repo_path)?;
43    Ok(())
44}
45
46fn excluded_files(repo_path: &Path, patterns: &[String]) -> Result<Vec<PathBuf>, PruneError> {
47    let exclusions = exclusions_file(patterns)?;
48    let mut exclude_from = OsString::from("--exclude-from=");
49    exclude_from.push(exclusions.path());
50    let git_dir = empty_git_dir()?;
51    index_every_file(git_dir.path(), repo_path)?;
52
53    // Only the index is read, so neither the config nor the `.gitignore`
54    // files of `repo_path` add rules.
55    let output = call::run_blocking(
56        "ls-files",
57        Command::new("git")
58            .arg("--git-dir")
59            .arg(git_dir.path())
60            .arg("--work-tree")
61            .arg(repo_path)
62            // A case-insensitive clone would otherwise match `docs` with `Docs`.
63            .args(["-c", "core.ignorecase=false"])
64            .args(["ls-files", "-z", "--cached", "--ignored"])
65            .arg(exclude_from)
66            // Git fails to start in a directory that no longer exists, so it runs inside the tree.
67            .current_dir(repo_path),
68    )?;
69
70    Ok(output
71        .stdout
72        .split(|byte| *byte == 0)
73        .filter(|file| !file.is_empty())
74        .map(path_from_bytes)
75        .collect())
76}
77
78// `ls-files --others` stops at a nested repository, so every file goes into
79// the index instead, by name alone: nothing reads the blob it points at.
80fn index_every_file(git_dir: &Path, repo_path: &Path) -> Result<(), PruneError> {
81    let mut entries = Vec::new();
82    for entry in WalkDir::new(repo_path)
83        .min_depth(1)
84        .into_iter()
85        .filter_entry(|entry| entry.file_name() != ".git")
86        .filter_map(Result::ok)
87        .filter(|entry| !entry.file_type().is_dir())
88    {
89        if let Ok(path) = entry.path().strip_prefix(repo_path) {
90            entries.extend_from_slice(format!("100644 {EMPTY_BLOB}\t").as_bytes());
91            entries.extend_from_slice(&path_to_bytes(path));
92            entries.push(0);
93        }
94    }
95    let mut file = NamedTempFile::new().map_err(file_error("creating", &env::temp_dir()))?;
96    file.write_all(&entries)
97        .map_err(file_error("writing", file.path()))?;
98    let stdin = file.reopen().map_err(file_error("reading", file.path()))?;
99
100    call::run_blocking(
101        "update-index",
102        Command::new("git")
103            .arg("--git-dir")
104            .arg(git_dir)
105            .args(["update-index", "-z", "--index-info"])
106            .stdin(stdin)
107            .current_dir(git_dir),
108    )?;
109    Ok(())
110}
111
112fn empty_git_dir() -> Result<TempDir, PruneError> {
113    let git_dir = TempDir::new().map_err(file_error("creating", &env::temp_dir()))?;
114    call::run_blocking(
115        "init",
116        Command::new("git")
117            .args(["init", "-q", "--bare"])
118            .arg(git_dir.path())
119            .current_dir(git_dir.path()),
120    )?;
121    Ok(git_dir)
122}
123
124// From a file git reads each exclusion as a `.gitignore` line, and the
125// command line stays short however many there are.
126fn exclusions_file(patterns: &[String]) -> Result<NamedTempFile, FileError> {
127    let mut file = NamedTempFile::new().map_err(file_error("creating", &env::temp_dir()))?;
128    // A line break would split one exclusion into several rules.
129    for pattern in patterns.iter().filter(|pattern| !pattern.contains('\n')) {
130        writeln!(file, "{pattern}").map_err(file_error("writing", file.path()))?;
131    }
132    Ok(file)
133}
134
135fn remove_empty_dirs(root: &Path) -> Result<(), FileError> {
136    let mut dirs: Vec<_> = Vec::new();
137
138    for entry in WalkDir::new(root) {
139        let entry = entry.map_err(|error| FileError {
140            action: "reading",
141            path: error.path().unwrap_or(root).to_path_buf(),
142            source: error.into(),
143        })?;
144        let path = entry.path();
145
146        let Ok(rel) = path.strip_prefix(root) else {
147            continue;
148        };
149
150        let rel_str = rel.to_string_lossy();
151
152        if rel_str == ".git" || rel_str.starts_with(".git/") || rel_str.starts_with(".git\\") {
153            continue;
154        }
155
156        if entry.file_type().is_dir() && path != root {
157            dirs.push(path.to_path_buf());
158        }
159    }
160
161    dirs.sort();
162    dirs.reverse();
163
164    for dir in dirs {
165        let is_empty = match fs::read_dir(&dir) {
166            Ok(mut entries) => entries.next().is_none(),
167            Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
168            Err(source) => return Err(file_error("reading", &dir)(source)),
169        };
170        if is_empty {
171            remove_if_present(&dir, fs::remove_dir)?;
172        }
173    }
174
175    Ok(())
176}
177
178fn file_error<'a>(
179    action: &'static str,
180    path: &'a Path,
181) -> impl FnOnce(io::Error) -> FileError + 'a {
182    move |source| FileError {
183        action,
184        path: path.to_path_buf(),
185        source,
186    }
187}
188
189// A path already gone is what the removal was after.
190fn remove_if_present<'a>(
191    path: &'a Path,
192    remove: fn(&'a Path) -> io::Result<()>,
193) -> Result<(), FileError> {
194    match remove(path) {
195        Err(source) if source.kind() != io::ErrorKind::NotFound => {
196            Err(file_error("removing", path)(source))
197        }
198        _ => Ok(()),
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::{delete_gitignored_files, remove_symlinks};
205
206    // A clone as the download leaves it, with every file in the index.
207    fn indexed_repo(files: &[&str]) -> tempfile::TempDir {
208        let dir = tempfile::tempdir().unwrap();
209        for file in files {
210            let path = dir.path().join(file);
211            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
212            std::fs::write(path, b"").unwrap();
213        }
214        for args in [&["init", "-q"][..], &["add", "-A"]] {
215            let status = std::process::Command::new("git")
216                .arg("-C")
217                .arg(dir.path())
218                .args(args)
219                .status()
220                .unwrap();
221            assert!(status.success());
222        }
223        dir
224    }
225
226    fn exclusions(patterns: &[&str]) -> Vec<String> {
227        patterns
228            .iter()
229            .map(|pattern| (*pattern).to_owned())
230            .collect()
231    }
232
233    #[test]
234    fn a_directory_pattern_deletes_what_is_under_it() {
235        let dir = indexed_repo(&["docs/a.md", "docs/sub/b.md", "keep.py"]);
236        let repo = dir.path();
237
238        delete_gitignored_files(repo, &exclusions(&["docs"])).unwrap();
239
240        assert!(!repo.join("docs").exists());
241        assert!(repo.join("keep.py").exists());
242        assert!(repo.join(".git/config").exists());
243    }
244
245    #[test]
246    fn a_directory_that_is_not_a_repository_is_pruned_too() {
247        let dir = tempfile::tempdir().unwrap();
248        let root = dir.path();
249        std::fs::create_dir_all(root.join("docs/sub")).unwrap();
250        std::fs::write(root.join("docs/sub/a.md"), b"").unwrap();
251        std::fs::write(root.join("keep.py"), b"").unwrap();
252
253        delete_gitignored_files(root, &exclusions(&["docs"])).unwrap();
254
255        assert!(!root.join("docs").exists());
256        assert!(root.join("keep.py").exists());
257    }
258
259    #[test]
260    fn a_file_in_a_nested_repository_is_deleted_too() {
261        let dir = indexed_repo(&["top.log", "inner/a.log", "inner/keep.py"]);
262        let repo = dir.path();
263        let status = std::process::Command::new("git")
264            .args(["init", "-q"])
265            .arg(repo.join("inner"))
266            .status()
267            .unwrap();
268        assert!(status.success());
269
270        delete_gitignored_files(repo, &exclusions(&["*.log"])).unwrap();
271
272        assert!(!repo.join("top.log").exists());
273        assert!(!repo.join("inner/a.log").exists());
274        assert!(repo.join("inner/keep.py").exists());
275        assert!(repo.join("inner/.git/HEAD").exists());
276    }
277
278    #[test]
279    fn the_gitignore_of_the_tree_adds_no_rules() {
280        let dir = indexed_repo(&["keep.py", "a.log"]);
281        let repo = dir.path();
282        std::fs::write(repo.join(".gitignore"), b"*.py\n").unwrap();
283
284        delete_gitignored_files(repo, &exclusions(&["*.log"])).unwrap();
285
286        assert!(repo.join("keep.py").exists());
287        assert!(!repo.join("a.log").exists());
288    }
289
290    #[test]
291    fn no_exclusions_keep_every_file() {
292        let dir = indexed_repo(&["keep.py"]);
293
294        delete_gitignored_files(dir.path(), &[]).unwrap();
295
296        assert!(dir.path().join("keep.py").exists());
297    }
298
299    #[test]
300    fn an_exclusion_that_matches_nothing_does_not_disable_the_others() {
301        let dir = indexed_repo(&["docs/a.md", "keep.py"]);
302        let repo = dir.path();
303
304        delete_gitignored_files(repo, &exclusions(&["docs", "[z-a]"])).unwrap();
305
306        assert!(!repo.join("docs/a.md").exists());
307        assert!(repo.join("keep.py").exists());
308    }
309
310    #[test]
311    fn an_unclosed_bracket_matches_nothing() {
312        let dir = indexed_repo(&["[main", "]"]);
313        let repo = dir.path();
314
315        delete_gitignored_files(repo, &exclusions(&["[main", "[]]"])).unwrap();
316
317        assert!(repo.join("[main").exists());
318        assert!(!repo.join("]").exists());
319    }
320
321    #[test]
322    fn braces_match_literally() {
323        let dir = indexed_repo(&["a/x", "{a,b}/x", "a}b", "cq"]);
324        let repo = dir.path();
325
326        delete_gitignored_files(repo, &exclusions(&["{a,b}/x", "a}b", "c[a-{]"])).unwrap();
327
328        assert!(repo.join("a/x").exists());
329        assert!(!repo.join("{a,b}/x").exists());
330        assert!(!repo.join("a}b").exists());
331        assert!(!repo.join("cq").exists());
332    }
333
334    #[test]
335    fn a_backslash_takes_the_next_character_literally() {
336        let dir = indexed_repo(&["a[{b}", "{a}", "ab"]);
337        let repo = dir.path();
338
339        delete_gitignored_files(repo, &exclusions(&[r"a\[{b}", r"\{a}"])).unwrap();
340
341        assert!(!repo.join("a[{b}").exists());
342        assert!(!repo.join("{a}").exists());
343        assert!(repo.join("ab").exists());
344    }
345
346    #[test]
347    fn a_named_class_matches_its_characters() {
348        let dir = indexed_repo(&["f5", "fx"]);
349        let repo = dir.path();
350
351        delete_gitignored_files(repo, &exclusions(&["f[[:digit:]]"])).unwrap();
352
353        assert!(!repo.join("f5").exists());
354        assert!(repo.join("fx").exists());
355    }
356
357    #[test]
358    fn an_exclusion_keeps_its_case_in_a_case_insensitive_clone() {
359        let dir = indexed_repo(&["README.md"]);
360        let repo = dir.path();
361        let status = std::process::Command::new("git")
362            .arg("-C")
363            .arg(repo)
364            .args(["config", "core.ignorecase", "true"])
365            .status()
366            .unwrap();
367        assert!(status.success());
368
369        delete_gitignored_files(repo, &exclusions(&["readme.md"])).unwrap();
370
371        assert!(repo.join("README.md").exists());
372    }
373
374    #[test]
375    fn an_exclusion_reads_as_a_gitignore_line() {
376        let dir = indexed_repo(&["#a", "b", "c"]);
377        let repo = dir.path();
378
379        delete_gitignored_files(repo, &exclusions(&["#a", "b ", "c"])).unwrap();
380
381        assert!(repo.join("#a").exists());
382        assert!(!repo.join("b").exists());
383        assert!(!repo.join("c").exists());
384    }
385
386    #[test]
387    fn an_untracked_file_is_deleted_too() {
388        let dir = indexed_repo(&["docs/a.md"]);
389        let repo = dir.path();
390        std::fs::write(repo.join("docs/new.md"), b"").unwrap();
391
392        delete_gitignored_files(repo, &exclusions(&["docs"])).unwrap();
393
394        assert!(!repo.join("docs").exists());
395    }
396
397    #[test]
398    fn an_exclusion_with_a_line_break_adds_no_rules() {
399        let dir = indexed_repo(&["a", "b"]);
400        let repo = dir.path();
401
402        delete_gitignored_files(repo, &exclusions(&["a\nb"])).unwrap();
403
404        assert!(repo.join("a").exists());
405        assert!(repo.join("b").exists());
406    }
407
408    #[test]
409    #[cfg(unix)]
410    fn an_unreadable_directory_does_not_stop_the_removal() {
411        use std::os::unix::fs::PermissionsExt;
412
413        let dir = tempfile::tempdir().unwrap();
414        let target = dir.path().join("target.txt");
415        std::fs::write(&target, b"content").unwrap();
416
417        let blocked = dir.path().join("b_blocked");
418        std::fs::create_dir(&blocked).unwrap();
419        let late = dir.path().join("c_late.lnk");
420        std::os::unix::fs::symlink(&target, &late).unwrap();
421        std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o000)).unwrap();
422
423        remove_symlinks(dir.path()).unwrap();
424
425        std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o755)).unwrap();
426        assert!(!late.is_symlink());
427        assert!(target.exists());
428    }
429
430    #[test]
431    #[cfg(unix)]
432    fn a_symlink_that_cannot_be_removed_is_reported() {
433        use std::os::unix::fs::PermissionsExt;
434
435        let dir = tempfile::tempdir().unwrap();
436        let sealed = dir.path().join("sealed");
437        std::fs::create_dir(&sealed).unwrap();
438        let link = sealed.join("link.lnk");
439        std::os::unix::fs::symlink(dir.path().join("target.txt"), &link).unwrap();
440        std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o500)).unwrap();
441
442        // A privileged user, as the ci runner is, writes into the directory
443        // regardless of its mode, and then there is no failure to report.
444        if std::fs::write(sealed.join("probe"), b"").is_ok() {
445            std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
446            return;
447        }
448
449        let result = remove_symlinks(dir.path());
450
451        std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
452        assert_eq!(result.unwrap_err().path, link);
453        assert!(link.is_symlink());
454    }
455
456    #[test]
457    #[cfg(unix)]
458    fn the_directory_itself_is_never_removed() {
459        let dir = tempfile::tempdir().unwrap();
460        let real = dir.path().join("real");
461        std::fs::create_dir(&real).unwrap();
462        let alias = dir.path().join("alias");
463        std::os::unix::fs::symlink(&real, &alias).unwrap();
464
465        remove_symlinks(&alias).unwrap();
466
467        assert!(alias.is_symlink());
468    }
469}