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