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;
8use walkdir::WalkDir;
9
10use crate::cli::path_from_bytes;
11
12pub fn remove_symlinks(dir: &Path) -> Result<()> {
13    for entry in WalkDir::new(dir)
14        .min_depth(1)
15        .into_iter()
16        .filter_map(Result::ok)
17    {
18        if entry.file_type().is_symlink() {
19            fs::remove_file(entry.path())?;
20        }
21    }
22
23    Ok(())
24}
25
26pub fn delete_gitignored_files(repo_path: &Path, patterns: &[String]) -> Result<()> {
27    // Git refuses `--ignored` without a pattern, and none excludes nothing.
28    if patterns.is_empty() {
29        return Ok(());
30    }
31    for file in excluded_files(repo_path, patterns)? {
32        let _ = fs::remove_file(repo_path.join(file));
33    }
34
35    remove_empty_dirs(repo_path)?;
36    Ok(())
37}
38
39fn excluded_files(repo_path: &Path, patterns: &[String]) -> Result<Vec<PathBuf>> {
40    let exclusions = exclusions_file(patterns)?;
41    let mut exclude_from = OsString::from("--exclude-from=");
42    exclude_from.push(exclusions.path());
43
44    let output = Command::new("git")
45        .arg("-C")
46        .arg(repo_path)
47        // A case-insensitive clone would otherwise match `docs` with `Docs`.
48        .args(["-c", "core.ignorecase=false"])
49        .args(["ls-files", "-z", "--cached", "--others", "--ignored"])
50        .arg(exclude_from)
51        .output()
52        .context("running git ls-files")?;
53    if !output.status.success() {
54        bail!(
55            "git ls-files failed: {}",
56            String::from_utf8_lossy(&output.stderr)
57        );
58    }
59
60    Ok(output
61        .stdout
62        .split(|byte| *byte == 0)
63        .filter(|file| !file.is_empty())
64        .map(path_from_bytes)
65        .collect())
66}
67
68// From a file git reads each exclusion as a `.gitignore` line, and the
69// command line stays short however many there are.
70fn exclusions_file(patterns: &[String]) -> Result<NamedTempFile> {
71    let mut file = NamedTempFile::new().context("creating the exclusions file")?;
72    // A line break would split one exclusion into several rules.
73    for pattern in patterns.iter().filter(|pattern| !pattern.contains('\n')) {
74        writeln!(file, "{pattern}").context("writing the exclusions file")?;
75    }
76    Ok(file)
77}
78
79pub fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
80    fs::create_dir_all(dst)?;
81    for entry in fs::read_dir(src)? {
82        let entry = entry?;
83        let ty = entry.file_type()?;
84        let dst_path = dst.join(entry.file_name());
85        if ty.is_dir() {
86            copy_dir_all(&entry.path(), &dst_path)?;
87        } else {
88            fs::copy(entry.path(), &dst_path)?;
89            copy_modified(&entry.path(), &dst_path);
90        }
91    }
92    Ok(())
93}
94
95fn copy_modified(src: &Path, dst: &Path) {
96    let Ok(modified) = fs::metadata(src).and_then(|meta| meta.modified()) else {
97        return;
98    };
99    if let Ok(file) = fs::File::open(dst) {
100        let _ = file.set_modified(modified);
101    }
102}
103
104fn remove_empty_dirs(root: &Path) -> Result<()> {
105    let mut dirs: Vec<_> = Vec::new();
106
107    for entry in WalkDir::new(root) {
108        let entry = entry?;
109        let path = entry.path();
110
111        let Ok(rel) = path.strip_prefix(root) else {
112            continue;
113        };
114
115        let rel_str = rel.to_string_lossy();
116
117        if rel_str == ".git" || rel_str.starts_with(".git/") || rel_str.starts_with(".git\\") {
118            continue;
119        }
120
121        if entry.file_type().is_dir() && path != root {
122            dirs.push(path.to_path_buf());
123        }
124    }
125
126    dirs.sort();
127    dirs.reverse();
128
129    for dir in dirs {
130        if let Ok(entries) = fs::read_dir(&dir) {
131            if entries.count() == 0 {
132                let _ = fs::remove_dir(&dir);
133            }
134        }
135    }
136
137    Ok(())
138}
139
140#[cfg(test)]
141mod tests {
142    use super::{copy_dir_all, delete_gitignored_files, remove_symlinks};
143    use std::time::{Duration, SystemTime};
144
145    // Git reads the patterns against its index, so the files go into one.
146    fn indexed_repo(files: &[&str]) -> tempfile::TempDir {
147        let dir = tempfile::tempdir().unwrap();
148        for file in files {
149            let path = dir.path().join(file);
150            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
151            std::fs::write(path, b"").unwrap();
152        }
153        for args in [&["init", "-q"][..], &["add", "-A"]] {
154            let status = std::process::Command::new("git")
155                .arg("-C")
156                .arg(dir.path())
157                .args(args)
158                .status()
159                .unwrap();
160            assert!(status.success());
161        }
162        dir
163    }
164
165    fn exclusions(patterns: &[&str]) -> Vec<String> {
166        patterns
167            .iter()
168            .map(|pattern| (*pattern).to_owned())
169            .collect()
170    }
171
172    #[test]
173    fn a_directory_pattern_deletes_what_is_under_it() {
174        let dir = indexed_repo(&["docs/a.md", "docs/sub/b.md", "keep.py"]);
175        let repo = dir.path();
176
177        delete_gitignored_files(repo, &exclusions(&["docs"])).unwrap();
178
179        assert!(!repo.join("docs").exists());
180        assert!(repo.join("keep.py").exists());
181        assert!(repo.join(".git/config").exists());
182    }
183
184    #[test]
185    fn no_exclusions_keep_every_file() {
186        let dir = indexed_repo(&["keep.py"]);
187
188        delete_gitignored_files(dir.path(), &[]).unwrap();
189
190        assert!(dir.path().join("keep.py").exists());
191    }
192
193    #[test]
194    fn an_exclusion_that_matches_nothing_does_not_disable_the_others() {
195        let dir = indexed_repo(&["docs/a.md", "keep.py"]);
196        let repo = dir.path();
197
198        delete_gitignored_files(repo, &exclusions(&["docs", "[z-a]"])).unwrap();
199
200        assert!(!repo.join("docs/a.md").exists());
201        assert!(repo.join("keep.py").exists());
202    }
203
204    #[test]
205    fn an_unclosed_bracket_matches_nothing() {
206        let dir = indexed_repo(&["[main", "]"]);
207        let repo = dir.path();
208
209        delete_gitignored_files(repo, &exclusions(&["[main", "[]]"])).unwrap();
210
211        assert!(repo.join("[main").exists());
212        assert!(!repo.join("]").exists());
213    }
214
215    #[test]
216    fn braces_match_literally() {
217        let dir = indexed_repo(&["a/x", "{a,b}/x", "a}b", "cq"]);
218        let repo = dir.path();
219
220        delete_gitignored_files(repo, &exclusions(&["{a,b}/x", "a}b", "c[a-{]"])).unwrap();
221
222        assert!(repo.join("a/x").exists());
223        assert!(!repo.join("{a,b}/x").exists());
224        assert!(!repo.join("a}b").exists());
225        assert!(!repo.join("cq").exists());
226    }
227
228    #[test]
229    fn a_backslash_takes_the_next_character_literally() {
230        let dir = indexed_repo(&["a[{b}", "{a}", "ab"]);
231        let repo = dir.path();
232
233        delete_gitignored_files(repo, &exclusions(&[r"a\[{b}", r"\{a}"])).unwrap();
234
235        assert!(!repo.join("a[{b}").exists());
236        assert!(!repo.join("{a}").exists());
237        assert!(repo.join("ab").exists());
238    }
239
240    #[test]
241    fn a_named_class_matches_its_characters() {
242        let dir = indexed_repo(&["f5", "fx"]);
243        let repo = dir.path();
244
245        delete_gitignored_files(repo, &exclusions(&["f[[:digit:]]"])).unwrap();
246
247        assert!(!repo.join("f5").exists());
248        assert!(repo.join("fx").exists());
249    }
250
251    #[test]
252    fn an_exclusion_keeps_its_case_in_a_case_insensitive_clone() {
253        let dir = indexed_repo(&["README.md"]);
254        let repo = dir.path();
255        let status = std::process::Command::new("git")
256            .arg("-C")
257            .arg(repo)
258            .args(["config", "core.ignorecase", "true"])
259            .status()
260            .unwrap();
261        assert!(status.success());
262
263        delete_gitignored_files(repo, &exclusions(&["readme.md"])).unwrap();
264
265        assert!(repo.join("README.md").exists());
266    }
267
268    #[test]
269    fn an_exclusion_reads_as_a_gitignore_line() {
270        let dir = indexed_repo(&["#a", "b", "c"]);
271        let repo = dir.path();
272
273        delete_gitignored_files(repo, &exclusions(&["#a", "b ", "c"])).unwrap();
274
275        assert!(repo.join("#a").exists());
276        assert!(!repo.join("b").exists());
277        assert!(!repo.join("c").exists());
278    }
279
280    #[test]
281    fn an_untracked_file_is_deleted_too() {
282        let dir = indexed_repo(&["docs/a.md"]);
283        let repo = dir.path();
284        std::fs::write(repo.join("docs/new.md"), b"").unwrap();
285
286        delete_gitignored_files(repo, &exclusions(&["docs"])).unwrap();
287
288        assert!(!repo.join("docs").exists());
289    }
290
291    #[test]
292    fn an_exclusion_with_a_line_break_adds_no_rules() {
293        let dir = indexed_repo(&["a", "b"]);
294        let repo = dir.path();
295
296        delete_gitignored_files(repo, &exclusions(&["a\nb"])).unwrap();
297
298        assert!(repo.join("a").exists());
299        assert!(repo.join("b").exists());
300    }
301
302    #[test]
303    fn a_copied_tree_keeps_the_mode_and_the_modification_time() {
304        let dir = tempfile::tempdir().unwrap();
305        let src = dir.path().join("src/objects");
306        std::fs::create_dir_all(&src).unwrap();
307        let object = src.join("a1b2c3");
308        std::fs::write(&object, b"blob").unwrap();
309
310        let modified = SystemTime::UNIX_EPOCH
311            .checked_add(Duration::from_secs(1_700_000_000))
312            .unwrap();
313        std::fs::File::open(&object)
314            .unwrap()
315            .set_modified(modified)
316            .unwrap();
317        #[cfg(unix)]
318        {
319            use std::os::unix::fs::PermissionsExt;
320            std::fs::set_permissions(&object, std::fs::Permissions::from_mode(0o444)).unwrap();
321        }
322
323        let dst = dir.path().join("dst");
324        copy_dir_all(&dir.path().join("src"), &dst).unwrap();
325
326        let copied = dst.join("objects/a1b2c3");
327        let meta = std::fs::metadata(&copied).unwrap();
328        assert_eq!(meta.modified().unwrap(), modified);
329        #[cfg(unix)]
330        {
331            use std::os::unix::fs::PermissionsExt;
332            assert_eq!(meta.permissions().mode() & 0o777, 0o444);
333        }
334    }
335
336    #[test]
337    #[cfg(unix)]
338    fn an_unreadable_directory_does_not_stop_the_removal() {
339        use std::os::unix::fs::PermissionsExt;
340
341        let dir = tempfile::tempdir().unwrap();
342        let target = dir.path().join("target.txt");
343        std::fs::write(&target, b"content").unwrap();
344
345        let blocked = dir.path().join("b_blocked");
346        std::fs::create_dir(&blocked).unwrap();
347        let late = dir.path().join("c_late.lnk");
348        std::os::unix::fs::symlink(&target, &late).unwrap();
349        std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o000)).unwrap();
350
351        remove_symlinks(dir.path()).unwrap();
352
353        std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o755)).unwrap();
354        assert!(!late.is_symlink());
355        assert!(target.exists());
356    }
357
358    #[test]
359    #[cfg(unix)]
360    fn a_symlink_that_cannot_be_removed_is_reported() {
361        use std::os::unix::fs::PermissionsExt;
362
363        let dir = tempfile::tempdir().unwrap();
364        let sealed = dir.path().join("sealed");
365        std::fs::create_dir(&sealed).unwrap();
366        let link = sealed.join("link.lnk");
367        std::os::unix::fs::symlink(dir.path().join("target.txt"), &link).unwrap();
368        std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o500)).unwrap();
369
370        // A privileged user, as the ci runner is, writes into the directory
371        // regardless of its mode, and then there is no failure to report.
372        if std::fs::write(sealed.join("probe"), b"").is_ok() {
373            std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
374            return;
375        }
376
377        let result = remove_symlinks(dir.path());
378
379        std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
380        assert!(result.is_err());
381        assert!(link.is_symlink());
382    }
383
384    #[test]
385    #[cfg(unix)]
386    fn the_directory_itself_is_never_removed() {
387        let dir = tempfile::tempdir().unwrap();
388        let real = dir.path().join("real");
389        std::fs::create_dir(&real).unwrap();
390        let alias = dir.path().join("alias");
391        std::os::unix::fs::symlink(&real, &alias).unwrap();
392
393        remove_symlinks(&alias).unwrap();
394
395        assert!(alias.is_symlink());
396    }
397}