1use std::path::Path;
7
8use crate::fs::{platform, FsRoot};
9
10#[derive(Debug, Default)]
23pub struct TreeOutcome {
24 pub removed: u64,
25 pub bytes: u64,
26 pub entries: Vec<String>,
27 pub truncated: bool,
28 pub failures: Vec<String>,
30}
31
32pub fn remove_tree(root: &FsRoot, target: &Path, dry_run: bool, limit: usize) -> TreeOutcome {
41 let mut outcome = TreeOutcome::default();
42 visit(root, target, dry_run, limit, &mut outcome);
43 outcome
44}
45
46fn visit(root: &FsRoot, path: &Path, dry_run: bool, limit: usize, out: &mut TreeOutcome) {
47 let Ok(meta) = std::fs::symlink_metadata(path) else {
49 out.failures.push(name_of(root, path));
50 return;
51 };
52
53 if meta.is_dir() {
54 match std::fs::read_dir(path) {
57 Ok(entries) => {
58 for entry in entries {
63 visit_entry(root, path, entry, dry_run, limit, out);
64 }
65 }
66 Err(_) => {
67 out.failures.push(name_of(root, path));
68 return;
69 }
70 }
71 }
72
73 out.removed += 1;
74 if !meta.is_dir() {
75 out.bytes += meta.len();
76 }
77 if out.entries.len() < limit {
78 out.entries.push(name_of(root, path));
79 } else {
80 out.truncated = true;
81 }
82
83 if !dry_run {
84 let result = if meta.is_dir() {
99 std::fs::remove_dir(path)
100 } else {
101 platform::remove_entry(path, &meta)
102 };
103 if result.is_err() {
104 out.failures.push(name_of(root, path));
105 }
106 }
107}
108
109fn visit_entry(
121 root: &FsRoot,
122 parent: &Path,
123 entry: std::io::Result<std::fs::DirEntry>,
124 dry_run: bool,
125 limit: usize,
126 out: &mut TreeOutcome,
127) {
128 match entry {
129 Ok(entry) => visit(root, &entry.path(), dry_run, limit, out),
130 Err(_) => out.failures.push(unreadable_entry_name(root, parent)),
133 }
134}
135
136fn unreadable_entry_name(root: &FsRoot, parent: &Path) -> String {
146 let parent_name = name_of(root, parent);
147 if parent_name.is_empty() {
148 "<unreadable entry>".to_string()
155 } else {
156 format!("{parent_name}/<unreadable entry>")
157 }
158}
159
160fn name_of(root: &FsRoot, path: &Path) -> String {
163 root.relative(path)
164 .unwrap_or_else(|| path.display().to_string())
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use crate::fs::FsRoot;
171
172 fn tree(files: &[&str]) -> (tempfile::TempDir, FsRoot) {
173 let dir = tempfile::tempdir().expect("tempdir");
174 for file in files {
175 let path = dir.path().join(file);
176 std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
177 std::fs::write(&path, b"xy").expect("write");
178 }
179 let root = FsRoot::new(dir.path()).expect("root");
180 (dir, root)
181 }
182
183 #[test]
184 fn a_dry_run_counts_everything_and_removes_nothing() {
185 let (dir, root) = tree(&["app/a.txt", "app/deep/b.txt"]);
186 let target = root.resolve_existing("app").expect("resolve");
187
188 let outcome = remove_tree(&root, &target, true, 100);
189
190 assert_eq!(outcome.removed, 4);
192 assert_eq!(outcome.bytes, 4, "두 파일 × 2바이트");
193 assert!(outcome.failures.is_empty());
194 assert!(dir.path().join("app/a.txt").exists());
196 assert!(dir.path().join("app/deep/b.txt").exists());
197 }
198
199 #[test]
200 fn a_real_run_removes_the_whole_tree() {
201 let (dir, root) = tree(&["app/a.txt", "app/deep/b.txt"]);
202 let target = root.resolve_existing("app").expect("resolve");
203
204 let outcome = remove_tree(&root, &target, false, 100);
205
206 assert_eq!(outcome.removed, 4);
207 assert!(outcome.failures.is_empty());
208 assert!(!dir.path().join("app").exists(), "트리가 사라져야 한다");
209 }
210
211 #[test]
213 fn the_listing_truncates_but_the_count_does_not() {
214 let (_dir, root) = tree(&["app/a.txt", "app/b.txt", "app/c.txt"]);
215 let target = root.resolve_existing("app").expect("resolve");
216
217 let outcome = remove_tree(&root, &target, true, 2);
218
219 assert_eq!(outcome.removed, 4, "app 자신 + 파일 3개");
220 assert_eq!(outcome.entries.len(), 2);
221 assert!(outcome.truncated);
222 }
223
224 fn try_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
230 #[cfg(unix)]
231 {
232 std::os::unix::fs::symlink(target, link)
233 }
234 #[cfg(windows)]
235 {
236 std::os::windows::fs::symlink_file(target, link)
237 }
238 #[cfg(not(any(unix, windows)))]
239 {
240 let _ = (target, link);
241 Err(std::io::Error::other(
242 "symlinks unsupported on this platform",
243 ))
244 }
245 }
246
247 fn try_symlink_dir(target: &Path, link: &Path) -> std::io::Result<()> {
251 #[cfg(unix)]
252 {
253 std::os::unix::fs::symlink(target, link)
254 }
255 #[cfg(windows)]
256 {
257 std::os::windows::fs::symlink_dir(target, link)
258 }
259 #[cfg(not(any(unix, windows)))]
260 {
261 let _ = (target, link);
262 Err(std::io::Error::other(
263 "symlinks unsupported on this platform",
264 ))
265 }
266 }
267
268 #[test]
277 fn an_entry_that_fails_to_enumerate_lands_in_failures() {
278 let (_dir, root) = tree(&["app/a.txt"]);
279 let parent = root.resolve_existing("app").expect("resolve");
280 let mut out = TreeOutcome::default();
281
282 visit_entry(
283 &root,
284 &parent,
285 Err(std::io::Error::other("enumeration failed")),
286 true,
287 100,
288 &mut out,
289 );
290
291 assert_eq!(out.failures, vec!["app/<unreadable entry>".to_string()]);
292 assert_eq!(out.removed, 0);
295 assert_eq!(out.bytes, 0);
296 assert!(out.entries.is_empty());
297 }
298
299 #[test]
302 fn an_unreadable_entry_at_the_root_is_not_named_with_a_leading_slash() {
303 let (_dir, root) = tree(&["app/a.txt"]);
304 let jail = root.jail_path().expect("이 픽스처는 jailed root를 만든다");
305 let mut out = TreeOutcome::default();
306
307 visit_entry(
308 &root,
309 jail,
310 Err(std::io::Error::other("enumeration failed")),
311 true,
312 100,
313 &mut out,
314 );
315
316 assert_eq!(out.failures, vec!["<unreadable entry>".to_string()]);
317 }
318
319 fn require_symlink(created: std::io::Result<()>, test_name: &str) {
340 if let Err(e) = created {
341 panic!("{test_name}: 심링크 생성 실패: {e} — 권한 문제라면 권한을 부여할 것(Windows: 개발자 모드 또는 SeCreateSymbolicLinkPrivilege). 테스트를 침묵시키는 것은 해법이 아니다.");
342 }
343 }
344
345 #[test]
347 fn a_symlink_is_removed_without_touching_its_target() {
348 let (dir, root) = tree(&["app/a.txt"]);
349 let outside = tempfile::tempdir().expect("outside");
350 let target_file = outside.path().join("keep.txt");
351 std::fs::write(&target_file, b"keep").expect("write");
352 require_symlink(
353 try_symlink(&target_file, &dir.path().join("app/link")),
354 "a_symlink_is_removed_without_touching_its_target",
355 );
356
357 let target = root.resolve_existing("app").expect("resolve");
358 let outcome = remove_tree(&root, &target, false, 100);
359
360 assert!(outcome.failures.is_empty(), "{:?}", outcome.failures);
361 assert!(!dir.path().join("app").exists());
362 assert!(target_file.exists(), "링크의 대상은 남아 있어야 한다");
363 }
364
365 #[test]
373 fn a_directory_symlink_is_removed_without_descending_into_its_target() {
374 let (dir, root) = tree(&["app/a.txt"]);
375 let outside = tempfile::tempdir().expect("outside");
376 let keep_dir = outside.path().join("keep_dir");
377 std::fs::create_dir(&keep_dir).expect("mkdir keep_dir");
378 let precious = keep_dir.join("precious.txt");
379 std::fs::write(&precious, b"precious").expect("write");
380 require_symlink(
381 try_symlink_dir(&keep_dir, &dir.path().join("app/dlink")),
382 "a_directory_symlink_is_removed_without_descending_into_its_target",
383 );
384
385 let target = root.resolve_existing("app").expect("resolve");
386
387 let preview = remove_tree(&root, &target, true, 100);
390 assert_eq!(
391 preview.removed, 3,
392 "app + a.txt + dlink, keep_dir 내용물은 세지 않는다"
393 );
394 assert!(preview.failures.is_empty(), "{:?}", preview.failures);
395 assert!(precious.exists(), "미리보기는 아무것도 지우지 않는다");
396
397 let outcome = remove_tree(&root, &target, false, 100);
398 assert_eq!(outcome.removed, 3);
399 assert!(outcome.failures.is_empty(), "{:?}", outcome.failures);
400 assert!(!dir.path().join("app").exists(), "트리가 사라져야 한다");
401 assert!(keep_dir.exists(), "링크의 대상 디렉터리는 남아 있어야 한다");
402 assert!(
403 precious.exists(),
404 "대상 디렉터리 안의 파일도 남아 있어야 한다"
405 );
406 }
407}