Skip to main content

fluidattacks_core_git/
fs.rs

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