Skip to main content

fluidattacks_core_git/
fs.rs

1use anyhow::Result;
2use ignore::gitignore::GitignoreBuilder;
3use std::fs;
4use std::path::Path;
5use walkdir::WalkDir;
6
7pub fn remove_symlinks(dir: &Path) -> Result<()> {
8    for entry in WalkDir::new(dir)
9        .min_depth(1)
10        .into_iter()
11        .filter_map(Result::ok)
12    {
13        if entry.file_type().is_symlink() {
14            fs::remove_file(entry.path())?;
15        }
16    }
17
18    Ok(())
19}
20
21pub fn delete_gitignored_files(repo_path: &Path, patterns: &[String]) -> Result<()> {
22    let mut builder = GitignoreBuilder::new(repo_path);
23    for pattern in patterns {
24        if let Err(error) = builder.add_line(None, pattern) {
25            tracing::warn!(pattern, error = %error, "skipping an exclusion git cannot compile");
26        }
27    }
28    let gitignore = builder.build()?;
29
30    for entry in WalkDir::new(repo_path) {
31        let entry = entry?;
32        let path = entry.path();
33
34        let Ok(rel) = path.strip_prefix(repo_path) else {
35            continue;
36        };
37
38        let rel_str = rel.to_string_lossy();
39
40        if rel_str == ".git" || rel_str.starts_with(".git/") || rel_str.starts_with(".git\\") {
41            continue;
42        }
43
44        if rel_str.is_empty() {
45            continue;
46        }
47
48        if !entry.file_type().is_dir()
49            && gitignore
50                .matched_path_or_any_parents(rel, false)
51                .is_ignore()
52        {
53            let _ = fs::remove_file(path);
54        }
55    }
56
57    remove_empty_dirs(repo_path)?;
58    Ok(())
59}
60
61pub fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
62    fs::create_dir_all(dst)?;
63    for entry in fs::read_dir(src)? {
64        let entry = entry?;
65        let ty = entry.file_type()?;
66        let dst_path = dst.join(entry.file_name());
67        if ty.is_dir() {
68            copy_dir_all(&entry.path(), &dst_path)?;
69        } else {
70            fs::copy(entry.path(), &dst_path)?;
71            copy_modified(&entry.path(), &dst_path);
72        }
73    }
74    Ok(())
75}
76
77fn copy_modified(src: &Path, dst: &Path) {
78    let Ok(modified) = fs::metadata(src).and_then(|meta| meta.modified()) else {
79        return;
80    };
81    if let Ok(file) = fs::File::open(dst) {
82        let _ = file.set_modified(modified);
83    }
84}
85
86fn remove_empty_dirs(root: &Path) -> Result<()> {
87    let mut dirs: Vec<_> = Vec::new();
88
89    for entry in WalkDir::new(root) {
90        let entry = entry?;
91        let path = entry.path();
92
93        let Ok(rel) = path.strip_prefix(root) else {
94            continue;
95        };
96
97        let rel_str = rel.to_string_lossy();
98
99        if rel_str == ".git" || rel_str.starts_with(".git/") || rel_str.starts_with(".git\\") {
100            continue;
101        }
102
103        if entry.file_type().is_dir() && path != root {
104            dirs.push(path.to_path_buf());
105        }
106    }
107
108    dirs.sort();
109    dirs.reverse();
110
111    for dir in dirs {
112        if let Ok(entries) = fs::read_dir(&dir) {
113            if entries.count() == 0 {
114                let _ = fs::remove_dir(&dir);
115            }
116        }
117    }
118
119    Ok(())
120}
121
122#[cfg(test)]
123mod tests {
124    use super::{copy_dir_all, delete_gitignored_files, remove_symlinks};
125    use std::time::{Duration, SystemTime};
126
127    #[test]
128    fn a_directory_pattern_deletes_what_is_under_it() {
129        let dir = tempfile::tempdir().unwrap();
130        let repo = dir.path();
131        std::fs::create_dir_all(repo.join("docs/sub")).unwrap();
132        std::fs::create_dir_all(repo.join(".git")).unwrap();
133        std::fs::write(repo.join("docs/a.md"), b"out").unwrap();
134        std::fs::write(repo.join("docs/sub/b.md"), b"out").unwrap();
135        std::fs::write(repo.join("keep.py"), b"in").unwrap();
136        std::fs::write(repo.join(".git/config"), b"[core]").unwrap();
137
138        delete_gitignored_files(repo, &["docs".to_owned()]).unwrap();
139
140        assert!(!repo.join("docs/a.md").exists());
141        assert!(!repo.join("docs/sub/b.md").exists());
142        assert!(repo.join("keep.py").exists());
143        assert!(repo.join(".git/config").exists());
144    }
145
146    #[test]
147    fn an_exclusion_git_cannot_compile_does_not_disable_the_others() {
148        let dir = tempfile::tempdir().unwrap();
149        let repo = dir.path();
150        std::fs::create_dir_all(repo.join("docs")).unwrap();
151        std::fs::write(repo.join("docs/a.md"), b"out").unwrap();
152        std::fs::write(repo.join("keep.py"), b"in").unwrap();
153
154        delete_gitignored_files(repo, &["docs".to_owned(), "src/{main".to_owned()]).unwrap();
155
156        assert!(!repo.join("docs/a.md").exists());
157        assert!(repo.join("keep.py").exists());
158    }
159
160    #[test]
161    fn a_copied_tree_keeps_the_mode_and_the_modification_time() {
162        let dir = tempfile::tempdir().unwrap();
163        let src = dir.path().join("src/objects");
164        std::fs::create_dir_all(&src).unwrap();
165        let object = src.join("a1b2c3");
166        std::fs::write(&object, b"blob").unwrap();
167
168        let modified = SystemTime::UNIX_EPOCH
169            .checked_add(Duration::from_secs(1_700_000_000))
170            .unwrap();
171        std::fs::File::open(&object)
172            .unwrap()
173            .set_modified(modified)
174            .unwrap();
175        #[cfg(unix)]
176        {
177            use std::os::unix::fs::PermissionsExt;
178            std::fs::set_permissions(&object, std::fs::Permissions::from_mode(0o444)).unwrap();
179        }
180
181        let dst = dir.path().join("dst");
182        copy_dir_all(&dir.path().join("src"), &dst).unwrap();
183
184        let copied = dst.join("objects/a1b2c3");
185        let meta = std::fs::metadata(&copied).unwrap();
186        assert_eq!(meta.modified().unwrap(), modified);
187        #[cfg(unix)]
188        {
189            use std::os::unix::fs::PermissionsExt;
190            assert_eq!(meta.permissions().mode() & 0o777, 0o444);
191        }
192    }
193
194    #[test]
195    #[cfg(unix)]
196    fn an_unreadable_directory_does_not_stop_the_removal() {
197        use std::os::unix::fs::PermissionsExt;
198
199        let dir = tempfile::tempdir().unwrap();
200        let target = dir.path().join("target.txt");
201        std::fs::write(&target, b"content").unwrap();
202
203        let blocked = dir.path().join("b_blocked");
204        std::fs::create_dir(&blocked).unwrap();
205        let late = dir.path().join("c_late.lnk");
206        std::os::unix::fs::symlink(&target, &late).unwrap();
207        std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o000)).unwrap();
208
209        remove_symlinks(dir.path()).unwrap();
210
211        std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o755)).unwrap();
212        assert!(!late.is_symlink());
213        assert!(target.exists());
214    }
215
216    #[test]
217    #[cfg(unix)]
218    fn a_symlink_that_cannot_be_removed_is_reported() {
219        use std::os::unix::fs::PermissionsExt;
220
221        let dir = tempfile::tempdir().unwrap();
222        let sealed = dir.path().join("sealed");
223        std::fs::create_dir(&sealed).unwrap();
224        let link = sealed.join("link.lnk");
225        std::os::unix::fs::symlink(dir.path().join("target.txt"), &link).unwrap();
226        std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o500)).unwrap();
227
228        // A privileged user, as the ci runner is, writes into the directory
229        // regardless of its mode, and then there is no failure to report.
230        if std::fs::write(sealed.join("probe"), b"").is_ok() {
231            std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
232            return;
233        }
234
235        let result = remove_symlinks(dir.path());
236
237        std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
238        assert!(result.is_err());
239        assert!(link.is_symlink());
240    }
241
242    #[test]
243    #[cfg(unix)]
244    fn the_directory_itself_is_never_removed() {
245        let dir = tempfile::tempdir().unwrap();
246        let real = dir.path().join("real");
247        std::fs::create_dir(&real).unwrap();
248        let alias = dir.path().join("alias");
249        std::os::unix::fs::symlink(&real, &alias).unwrap();
250
251        remove_symlinks(&alias).unwrap();
252
253        assert!(alias.is_symlink());
254    }
255}