Skip to main content

fluidattacks_core_git/
fs.rs

1use 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, 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            fs::remove_file(entry.path())?;
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    for file in excluded_files(repo_path, patterns)? {
34        let _ = fs::remove_file(repo_path.join(file));
35    }
36
37    remove_empty_dirs(repo_path)?;
38    Ok(())
39}
40
41fn excluded_files(repo_path: &Path, patterns: &[String]) -> Result<Vec<PathBuf>> {
42    let exclusions = exclusions_file(patterns)?;
43    let mut exclude_from = OsString::from("--exclude-from=");
44    exclude_from.push(exclusions.path());
45    let git_dir = empty_git_dir()?;
46    index_every_file(git_dir.path(), repo_path)?;
47
48    // Only the index is read, so neither the config nor the `.gitignore`
49    // files of `repo_path` add rules.
50    let output = Command::new("git")
51        .arg("--git-dir")
52        .arg(git_dir.path())
53        .arg("--work-tree")
54        .arg(repo_path)
55        // A case-insensitive clone would otherwise match `docs` with `Docs`.
56        .args(["-c", "core.ignorecase=false"])
57        .args(["ls-files", "-z", "--cached", "--ignored"])
58        .arg(exclude_from)
59        .output()
60        .context("running git ls-files")?;
61    if !output.status.success() {
62        bail!(
63            "git ls-files failed: {}",
64            String::from_utf8_lossy(&output.stderr)
65        );
66    }
67
68    Ok(output
69        .stdout
70        .split(|byte| *byte == 0)
71        .filter(|file| !file.is_empty())
72        .map(path_from_bytes)
73        .collect())
74}
75
76// `ls-files --others` stops at a nested repository, so every file goes into
77// the index instead, by name alone: nothing reads the blob it points at.
78fn index_every_file(git_dir: &Path, repo_path: &Path) -> Result<()> {
79    let mut entries = Vec::new();
80    for entry in WalkDir::new(repo_path)
81        .min_depth(1)
82        .into_iter()
83        .filter_entry(|entry| entry.file_name() != ".git")
84        .filter_map(Result::ok)
85        .filter(|entry| !entry.file_type().is_dir())
86    {
87        if let Ok(path) = entry.path().strip_prefix(repo_path) {
88            entries.extend_from_slice(format!("100644 {EMPTY_BLOB}\t").as_bytes());
89            entries.extend_from_slice(&path_to_bytes(path));
90            entries.push(0);
91        }
92    }
93    let mut file = NamedTempFile::new().context("creating the index entries")?;
94    file.write_all(&entries)
95        .context("writing the index entries")?;
96
97    let output = Command::new("git")
98        .arg("--git-dir")
99        .arg(git_dir)
100        .args(["update-index", "-z", "--index-info"])
101        .stdin(file.reopen().context("reading the index entries")?)
102        .output()
103        .context("running git update-index")?;
104    if !output.status.success() {
105        bail!(
106            "git update-index failed: {}",
107            String::from_utf8_lossy(&output.stderr)
108        );
109    }
110    Ok(())
111}
112
113fn empty_git_dir() -> Result<TempDir> {
114    let git_dir = TempDir::new().context("creating the git dir")?;
115    let output = Command::new("git")
116        .args(["init", "-q", "--bare"])
117        .arg(git_dir.path())
118        .output()
119        .context("running git init")?;
120    if !output.status.success() {
121        bail!(
122            "git init failed: {}",
123            String::from_utf8_lossy(&output.stderr)
124        );
125    }
126    Ok(git_dir)
127}
128
129// From a file git reads each exclusion as a `.gitignore` line, and the
130// command line stays short however many there are.
131fn exclusions_file(patterns: &[String]) -> Result<NamedTempFile> {
132    let mut file = NamedTempFile::new().context("creating the exclusions file")?;
133    // A line break would split one exclusion into several rules.
134    for pattern in patterns.iter().filter(|pattern| !pattern.contains('\n')) {
135        writeln!(file, "{pattern}").context("writing the exclusions file")?;
136    }
137    Ok(file)
138}
139
140pub fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
141    fs::create_dir_all(dst)?;
142    for entry in fs::read_dir(src)? {
143        let entry = entry?;
144        let ty = entry.file_type()?;
145        let dst_path = dst.join(entry.file_name());
146        if ty.is_dir() {
147            copy_dir_all(&entry.path(), &dst_path)?;
148        } else {
149            fs::copy(entry.path(), &dst_path)?;
150            copy_modified(&entry.path(), &dst_path);
151        }
152    }
153    Ok(())
154}
155
156fn copy_modified(src: &Path, dst: &Path) {
157    let Ok(modified) = fs::metadata(src).and_then(|meta| meta.modified()) else {
158        return;
159    };
160    if let Ok(file) = fs::File::open(dst) {
161        let _ = file.set_modified(modified);
162    }
163}
164
165fn remove_empty_dirs(root: &Path) -> Result<()> {
166    let mut dirs: Vec<_> = Vec::new();
167
168    for entry in WalkDir::new(root) {
169        let entry = entry?;
170        let path = entry.path();
171
172        let Ok(rel) = path.strip_prefix(root) else {
173            continue;
174        };
175
176        let rel_str = rel.to_string_lossy();
177
178        if rel_str == ".git" || rel_str.starts_with(".git/") || rel_str.starts_with(".git\\") {
179            continue;
180        }
181
182        if entry.file_type().is_dir() && path != root {
183            dirs.push(path.to_path_buf());
184        }
185    }
186
187    dirs.sort();
188    dirs.reverse();
189
190    for dir in dirs {
191        if let Ok(entries) = fs::read_dir(&dir) {
192            if entries.count() == 0 {
193                let _ = fs::remove_dir(&dir);
194            }
195        }
196    }
197
198    Ok(())
199}
200
201#[cfg(test)]
202mod tests {
203    use super::{copy_dir_all, delete_gitignored_files, remove_symlinks};
204    use std::time::{Duration, SystemTime};
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    fn a_copied_tree_keeps_the_mode_and_the_modification_time() {
410        let dir = tempfile::tempdir().unwrap();
411        let src = dir.path().join("src/objects");
412        std::fs::create_dir_all(&src).unwrap();
413        let object = src.join("a1b2c3");
414        std::fs::write(&object, b"blob").unwrap();
415
416        let modified = SystemTime::UNIX_EPOCH
417            .checked_add(Duration::from_secs(1_700_000_000))
418            .unwrap();
419        std::fs::File::open(&object)
420            .unwrap()
421            .set_modified(modified)
422            .unwrap();
423        #[cfg(unix)]
424        {
425            use std::os::unix::fs::PermissionsExt;
426            std::fs::set_permissions(&object, std::fs::Permissions::from_mode(0o444)).unwrap();
427        }
428
429        let dst = dir.path().join("dst");
430        copy_dir_all(&dir.path().join("src"), &dst).unwrap();
431
432        let copied = dst.join("objects/a1b2c3");
433        let meta = std::fs::metadata(&copied).unwrap();
434        assert_eq!(meta.modified().unwrap(), modified);
435        #[cfg(unix)]
436        {
437            use std::os::unix::fs::PermissionsExt;
438            assert_eq!(meta.permissions().mode() & 0o777, 0o444);
439        }
440    }
441
442    #[test]
443    #[cfg(unix)]
444    fn an_unreadable_directory_does_not_stop_the_removal() {
445        use std::os::unix::fs::PermissionsExt;
446
447        let dir = tempfile::tempdir().unwrap();
448        let target = dir.path().join("target.txt");
449        std::fs::write(&target, b"content").unwrap();
450
451        let blocked = dir.path().join("b_blocked");
452        std::fs::create_dir(&blocked).unwrap();
453        let late = dir.path().join("c_late.lnk");
454        std::os::unix::fs::symlink(&target, &late).unwrap();
455        std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o000)).unwrap();
456
457        remove_symlinks(dir.path()).unwrap();
458
459        std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o755)).unwrap();
460        assert!(!late.is_symlink());
461        assert!(target.exists());
462    }
463
464    #[test]
465    #[cfg(unix)]
466    fn a_symlink_that_cannot_be_removed_is_reported() {
467        use std::os::unix::fs::PermissionsExt;
468
469        let dir = tempfile::tempdir().unwrap();
470        let sealed = dir.path().join("sealed");
471        std::fs::create_dir(&sealed).unwrap();
472        let link = sealed.join("link.lnk");
473        std::os::unix::fs::symlink(dir.path().join("target.txt"), &link).unwrap();
474        std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o500)).unwrap();
475
476        // A privileged user, as the ci runner is, writes into the directory
477        // regardless of its mode, and then there is no failure to report.
478        if std::fs::write(sealed.join("probe"), b"").is_ok() {
479            std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
480            return;
481        }
482
483        let result = remove_symlinks(dir.path());
484
485        std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
486        assert!(result.is_err());
487        assert!(link.is_symlink());
488    }
489
490    #[test]
491    #[cfg(unix)]
492    fn the_directory_itself_is_never_removed() {
493        let dir = tempfile::tempdir().unwrap();
494        let real = dir.path().join("real");
495        std::fs::create_dir(&real).unwrap();
496        let alias = dir.path().join("alias");
497        std::os::unix::fs::symlink(&real, &alias).unwrap();
498
499        remove_symlinks(&alias).unwrap();
500
501        assert!(alias.is_symlink());
502    }
503}