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