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
148pub fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
149    fs::create_dir_all(dst)?;
150    for entry in fs::read_dir(src)? {
151        let entry = entry?;
152        let ty = entry.file_type()?;
153        let dst_path = dst.join(entry.file_name());
154        if ty.is_dir() {
155            copy_dir_all(&entry.path(), &dst_path)?;
156        } else {
157            fs::copy(entry.path(), &dst_path)?;
158            copy_modified(&entry.path(), &dst_path);
159        }
160    }
161    Ok(())
162}
163
164fn copy_modified(src: &Path, dst: &Path) {
165    let Ok(modified) = fs::metadata(src).and_then(|meta| meta.modified()) else {
166        return;
167    };
168    if let Ok(file) = fs::File::open(dst) {
169        let _ = file.set_modified(modified);
170    }
171}
172
173fn remove_empty_dirs(root: &Path) -> Result<()> {
174    let mut dirs: Vec<_> = Vec::new();
175
176    for entry in WalkDir::new(root) {
177        let entry = entry?;
178        let path = entry.path();
179
180        let Ok(rel) = path.strip_prefix(root) else {
181            continue;
182        };
183
184        let rel_str = rel.to_string_lossy();
185
186        if rel_str == ".git" || rel_str.starts_with(".git/") || rel_str.starts_with(".git\\") {
187            continue;
188        }
189
190        if entry.file_type().is_dir() && path != root {
191            dirs.push(path.to_path_buf());
192        }
193    }
194
195    dirs.sort();
196    dirs.reverse();
197
198    for dir in dirs {
199        let is_empty = match fs::read_dir(&dir) {
200            Ok(mut entries) => entries.next().is_none(),
201            Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
202            Err(error) => {
203                return Err(error).with_context(|| format!("reading {}", dir.display()));
204            }
205        };
206        if is_empty {
207            remove_if_present(&dir, fs::remove_dir)?;
208        }
209    }
210
211    Ok(())
212}
213
214// A path already gone is what the removal was after.
215fn remove_if_present<'a>(path: &'a Path, remove: fn(&'a Path) -> io::Result<()>) -> Result<()> {
216    match remove(path) {
217        Err(error) if error.kind() != io::ErrorKind::NotFound => {
218            Err(error).with_context(|| format!("removing {}", path.display()))
219        }
220        _ => Ok(()),
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::{copy_dir_all, delete_gitignored_files, remove_symlinks};
227    use std::time::{Duration, SystemTime};
228
229    // A clone as the download leaves it, with every file in the index.
230    fn indexed_repo(files: &[&str]) -> tempfile::TempDir {
231        let dir = tempfile::tempdir().unwrap();
232        for file in files {
233            let path = dir.path().join(file);
234            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
235            std::fs::write(path, b"").unwrap();
236        }
237        for args in [&["init", "-q"][..], &["add", "-A"]] {
238            let status = std::process::Command::new("git")
239                .arg("-C")
240                .arg(dir.path())
241                .args(args)
242                .status()
243                .unwrap();
244            assert!(status.success());
245        }
246        dir
247    }
248
249    fn exclusions(patterns: &[&str]) -> Vec<String> {
250        patterns
251            .iter()
252            .map(|pattern| (*pattern).to_owned())
253            .collect()
254    }
255
256    #[test]
257    fn a_directory_pattern_deletes_what_is_under_it() {
258        let dir = indexed_repo(&["docs/a.md", "docs/sub/b.md", "keep.py"]);
259        let repo = dir.path();
260
261        delete_gitignored_files(repo, &exclusions(&["docs"])).unwrap();
262
263        assert!(!repo.join("docs").exists());
264        assert!(repo.join("keep.py").exists());
265        assert!(repo.join(".git/config").exists());
266    }
267
268    #[test]
269    fn a_directory_that_is_not_a_repository_is_pruned_too() {
270        let dir = tempfile::tempdir().unwrap();
271        let root = dir.path();
272        std::fs::create_dir_all(root.join("docs/sub")).unwrap();
273        std::fs::write(root.join("docs/sub/a.md"), b"").unwrap();
274        std::fs::write(root.join("keep.py"), b"").unwrap();
275
276        delete_gitignored_files(root, &exclusions(&["docs"])).unwrap();
277
278        assert!(!root.join("docs").exists());
279        assert!(root.join("keep.py").exists());
280    }
281
282    #[test]
283    fn a_file_in_a_nested_repository_is_deleted_too() {
284        let dir = indexed_repo(&["top.log", "inner/a.log", "inner/keep.py"]);
285        let repo = dir.path();
286        let status = std::process::Command::new("git")
287            .args(["init", "-q"])
288            .arg(repo.join("inner"))
289            .status()
290            .unwrap();
291        assert!(status.success());
292
293        delete_gitignored_files(repo, &exclusions(&["*.log"])).unwrap();
294
295        assert!(!repo.join("top.log").exists());
296        assert!(!repo.join("inner/a.log").exists());
297        assert!(repo.join("inner/keep.py").exists());
298        assert!(repo.join("inner/.git/HEAD").exists());
299    }
300
301    #[test]
302    fn the_gitignore_of_the_tree_adds_no_rules() {
303        let dir = indexed_repo(&["keep.py", "a.log"]);
304        let repo = dir.path();
305        std::fs::write(repo.join(".gitignore"), b"*.py\n").unwrap();
306
307        delete_gitignored_files(repo, &exclusions(&["*.log"])).unwrap();
308
309        assert!(repo.join("keep.py").exists());
310        assert!(!repo.join("a.log").exists());
311    }
312
313    #[test]
314    fn no_exclusions_keep_every_file() {
315        let dir = indexed_repo(&["keep.py"]);
316
317        delete_gitignored_files(dir.path(), &[]).unwrap();
318
319        assert!(dir.path().join("keep.py").exists());
320    }
321
322    #[test]
323    fn an_exclusion_that_matches_nothing_does_not_disable_the_others() {
324        let dir = indexed_repo(&["docs/a.md", "keep.py"]);
325        let repo = dir.path();
326
327        delete_gitignored_files(repo, &exclusions(&["docs", "[z-a]"])).unwrap();
328
329        assert!(!repo.join("docs/a.md").exists());
330        assert!(repo.join("keep.py").exists());
331    }
332
333    #[test]
334    fn an_unclosed_bracket_matches_nothing() {
335        let dir = indexed_repo(&["[main", "]"]);
336        let repo = dir.path();
337
338        delete_gitignored_files(repo, &exclusions(&["[main", "[]]"])).unwrap();
339
340        assert!(repo.join("[main").exists());
341        assert!(!repo.join("]").exists());
342    }
343
344    #[test]
345    fn braces_match_literally() {
346        let dir = indexed_repo(&["a/x", "{a,b}/x", "a}b", "cq"]);
347        let repo = dir.path();
348
349        delete_gitignored_files(repo, &exclusions(&["{a,b}/x", "a}b", "c[a-{]"])).unwrap();
350
351        assert!(repo.join("a/x").exists());
352        assert!(!repo.join("{a,b}/x").exists());
353        assert!(!repo.join("a}b").exists());
354        assert!(!repo.join("cq").exists());
355    }
356
357    #[test]
358    fn a_backslash_takes_the_next_character_literally() {
359        let dir = indexed_repo(&["a[{b}", "{a}", "ab"]);
360        let repo = dir.path();
361
362        delete_gitignored_files(repo, &exclusions(&[r"a\[{b}", r"\{a}"])).unwrap();
363
364        assert!(!repo.join("a[{b}").exists());
365        assert!(!repo.join("{a}").exists());
366        assert!(repo.join("ab").exists());
367    }
368
369    #[test]
370    fn a_named_class_matches_its_characters() {
371        let dir = indexed_repo(&["f5", "fx"]);
372        let repo = dir.path();
373
374        delete_gitignored_files(repo, &exclusions(&["f[[:digit:]]"])).unwrap();
375
376        assert!(!repo.join("f5").exists());
377        assert!(repo.join("fx").exists());
378    }
379
380    #[test]
381    fn an_exclusion_keeps_its_case_in_a_case_insensitive_clone() {
382        let dir = indexed_repo(&["README.md"]);
383        let repo = dir.path();
384        let status = std::process::Command::new("git")
385            .arg("-C")
386            .arg(repo)
387            .args(["config", "core.ignorecase", "true"])
388            .status()
389            .unwrap();
390        assert!(status.success());
391
392        delete_gitignored_files(repo, &exclusions(&["readme.md"])).unwrap();
393
394        assert!(repo.join("README.md").exists());
395    }
396
397    #[test]
398    fn an_exclusion_reads_as_a_gitignore_line() {
399        let dir = indexed_repo(&["#a", "b", "c"]);
400        let repo = dir.path();
401
402        delete_gitignored_files(repo, &exclusions(&["#a", "b ", "c"])).unwrap();
403
404        assert!(repo.join("#a").exists());
405        assert!(!repo.join("b").exists());
406        assert!(!repo.join("c").exists());
407    }
408
409    #[test]
410    fn an_untracked_file_is_deleted_too() {
411        let dir = indexed_repo(&["docs/a.md"]);
412        let repo = dir.path();
413        std::fs::write(repo.join("docs/new.md"), b"").unwrap();
414
415        delete_gitignored_files(repo, &exclusions(&["docs"])).unwrap();
416
417        assert!(!repo.join("docs").exists());
418    }
419
420    #[test]
421    fn an_exclusion_with_a_line_break_adds_no_rules() {
422        let dir = indexed_repo(&["a", "b"]);
423        let repo = dir.path();
424
425        delete_gitignored_files(repo, &exclusions(&["a\nb"])).unwrap();
426
427        assert!(repo.join("a").exists());
428        assert!(repo.join("b").exists());
429    }
430
431    #[test]
432    fn a_copied_tree_keeps_the_mode_and_the_modification_time() {
433        let dir = tempfile::tempdir().unwrap();
434        let src = dir.path().join("src/objects");
435        std::fs::create_dir_all(&src).unwrap();
436        let object = src.join("a1b2c3");
437        std::fs::write(&object, b"blob").unwrap();
438
439        let modified = SystemTime::UNIX_EPOCH
440            .checked_add(Duration::from_secs(1_700_000_000))
441            .unwrap();
442        std::fs::File::open(&object)
443            .unwrap()
444            .set_modified(modified)
445            .unwrap();
446        #[cfg(unix)]
447        {
448            use std::os::unix::fs::PermissionsExt;
449            std::fs::set_permissions(&object, std::fs::Permissions::from_mode(0o444)).unwrap();
450        }
451
452        let dst = dir.path().join("dst");
453        copy_dir_all(&dir.path().join("src"), &dst).unwrap();
454
455        let copied = dst.join("objects/a1b2c3");
456        let meta = std::fs::metadata(&copied).unwrap();
457        assert_eq!(meta.modified().unwrap(), modified);
458        #[cfg(unix)]
459        {
460            use std::os::unix::fs::PermissionsExt;
461            assert_eq!(meta.permissions().mode() & 0o777, 0o444);
462        }
463    }
464
465    #[test]
466    #[cfg(unix)]
467    fn an_unreadable_directory_does_not_stop_the_removal() {
468        use std::os::unix::fs::PermissionsExt;
469
470        let dir = tempfile::tempdir().unwrap();
471        let target = dir.path().join("target.txt");
472        std::fs::write(&target, b"content").unwrap();
473
474        let blocked = dir.path().join("b_blocked");
475        std::fs::create_dir(&blocked).unwrap();
476        let late = dir.path().join("c_late.lnk");
477        std::os::unix::fs::symlink(&target, &late).unwrap();
478        std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o000)).unwrap();
479
480        remove_symlinks(dir.path()).unwrap();
481
482        std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o755)).unwrap();
483        assert!(!late.is_symlink());
484        assert!(target.exists());
485    }
486
487    #[test]
488    #[cfg(unix)]
489    fn a_symlink_that_cannot_be_removed_is_reported() {
490        use std::os::unix::fs::PermissionsExt;
491
492        let dir = tempfile::tempdir().unwrap();
493        let sealed = dir.path().join("sealed");
494        std::fs::create_dir(&sealed).unwrap();
495        let link = sealed.join("link.lnk");
496        std::os::unix::fs::symlink(dir.path().join("target.txt"), &link).unwrap();
497        std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o500)).unwrap();
498
499        // A privileged user, as the ci runner is, writes into the directory
500        // regardless of its mode, and then there is no failure to report.
501        if std::fs::write(sealed.join("probe"), b"").is_ok() {
502            std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
503            return;
504        }
505
506        let result = remove_symlinks(dir.path());
507
508        std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
509        assert!(result.is_err());
510        assert!(link.is_symlink());
511    }
512
513    #[test]
514    #[cfg(unix)]
515    fn the_directory_itself_is_never_removed() {
516        let dir = tempfile::tempdir().unwrap();
517        let real = dir.path().join("real");
518        std::fs::create_dir(&real).unwrap();
519        let alias = dir.path().join("alias");
520        std::os::unix::fs::symlink(&real, &alias).unwrap();
521
522        remove_symlinks(&alias).unwrap();
523
524        assert!(alias.is_symlink());
525    }
526}