Skip to main content

shell_tunnel/fs/
tree.rs

1//! 디렉터리 트리를 세거나 제거한다.
2//!
3//! 미리보기(`dry_run`)와 실행이 **같은 순회를 통과**한다. 갈라두면 미리보기가
4//! 거짓말을 하게 되고, 그 거짓말은 지운 뒤에야 드러난다.
5
6use std::path::Path;
7
8use crate::fs::{platform, FsRoot};
9
10/// 한 번의 트리 연산 결과.
11///
12/// `removed`/`bytes`는 **`failures`가 비어 있을 때만** 정확하다. 비어 있지
13/// 않으면 두 방향으로 어긋난다: 열거나 stat에 실패한 항목은 이름도 크기도
14/// 몰라 아예 세지 못했으므로 `removed`/`bytes`는 하한이고, 제거에 실패한
15/// 항목은 세어진 뒤에 실패했으므로 `removed`는 "지워진 개수"가 아니라
16/// "세어서 시도한 개수"다. 어느 쪽이든 `failures`가 비어 있는지부터 봐야
17/// 한다.
18///
19/// `entries`는 `limit`까지만 담고 넘치면 `truncated`가 선다 — 릴레이 본문
20/// 상한이 8 MiB이고, 이 응답의 목적은 "얼마나 큰 일인가"를 알리는 것이지
21/// 목록을 완전히 나르는 것이 아니다.
22#[derive(Debug, Default)]
23pub struct TreeOutcome {
24    pub removed: u64,
25    pub bytes: u64,
26    pub entries: Vec<String>,
27    pub truncated: bool,
28    /// 제거에 실패한 항목. 비어 있지 않으면 호출자가 부분 실패로 보고한다.
29    pub failures: Vec<String>,
30}
31
32/// `target` 아래를 전부 세고, `dry_run`이 아니면 제거한다.
33///
34/// 자식을 먼저 처리하고 부모를 나중에 처리한다 — 반대로 하면 부모를 지운
35/// 뒤 자식을 셀 수 없다.
36///
37/// 심볼릭 링크는 **따라가지 않는다**. 링크는 그 자체가 한 항목이고,
38/// `platform::remove_entry`가 플랫폼별 올바른 제거(Windows 디렉터리 reparse
39/// point 포함)를 한다. 따라가면 트리 밖을 지운다.
40pub 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    // lstat: 링크를 대상으로 착각하면 링크를 디렉터리로 보고 따라 들어간다.
48    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        // `is_dir()`은 lstat 결과이므로 심링크에는 서지 않는다 — 진짜
55        // 디렉터리일 때만 내려간다.
56        match std::fs::read_dir(path) {
57            Ok(entries) => {
58                // `entries`를 그대로 넘긴다. `.flatten()`을 끼우면 `Err`이
59                // 여기서 사라져 `visit_entry`의 `Err` 갈래가 영영 안 불린다
60                // — 그래도 테스트는 전부 통과하므로(확인함) 이 한 줄은
61                // 리뷰로만 지켜진다.
62                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        // `platform::remove_entry` is for symlinks -- it unlinks the link
85        // itself with whichever syscall that needs. A real directory is not
86        // its target: the function's own doc comment says its one existing
87        // caller refuses directories before ever reaching it, so calling it
88        // here on a directory would try to unlink a directory as a file and
89        // fail. `meta.is_dir()` comes from `symlink_metadata` (lstat), so it
90        // is true only for a genuine directory, never a symlink -- a
91        // directory symlink still goes through `remove_entry` below.
92        //
93        // A recursive walk empties a directory before reaching it here, so
94        // `remove_dir` is sufficient. It is also a safety net: `remove_dir`
95        // fails on a non-empty directory, so if the children-first order
96        // were ever broken, this fails loudly instead of silently leaving
97        // files behind.
98        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
109/// 열거가 내놓은 항목 하나를 처리한다.
110///
111/// `read_dir`의 이터레이터는 `io::Result<DirEntry>`를 낸다. 이 갈래가 별도
112/// 함수인 것은 `Err`을 버리지 않는다는 결정을 테스트가 직접 붙잡을 수 있게
113/// 하기 위해서다 — 이터레이터가 `Err`을 내도록 플랫폼 독립적으로 유도할
114/// 방법이 없다.
115///
116/// 예전에는 `.flatten()`으로 받아 `Err`을 말없이 버렸다. 그러면 그 항목이
117/// `removed`에도 `failures`에도 남지 않는다. 실제 삭제에서는 나중에 부모의
118/// `remove_dir`이 "비어 있지 않음"으로 실패해 결국 드러나지만, `dry_run`에는
119/// 그 안전망이 없어 미리보기가 `failures`를 비운 채 개수를 틀리게 답했다.
120fn 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        // `entries`에도 `removed`에도 넣지 않는다 — 이름도 크기도 모르는 것을
131        // 셀 수는 없다. 위 `symlink_metadata` 실패 경로와 같은 처리다.
132        Err(_) => out.failures.push(unreadable_entry_name(root, parent)),
133    }
134}
135
136/// 열거에 실패해 경로조차 모르는 항목의 이름. 아는 것은 어느 디렉터리 안에
137/// 있었는가뿐이므로 부모 이름에 매단다.
138///
139/// 부모 **자신**의 실패는 `name_of`가 낸 이름 그대로 들어가므로 두 사유가
140/// 같은 문자열로 섞이지 않는다. `<`/`>`는 Windows 파일명에 쓸 수 없고
141/// Unix에서도 드물어 진짜 경로로 오해되지 않는다.
142///
143/// 한 디렉터리에서 N개가 실패하면 같은 문자열이 N번 들어간다. 그 개수가
144/// 정보이므로 의도된 것이다 — 나중에 중복 제거로 "고치지" 말 것.
145fn unreadable_entry_name(root: &FsRoot, parent: &Path) -> String {
146    let parent_name = name_of(root, parent);
147    if parent_name.is_empty() {
148        // 부모가 jail 루트 자신이면 `relative`는 빈 문자열을 준다. 그대로
149        // 이으면 `/<unreadable entry>`가 되어 절대경로처럼 보인다.
150        //
151        // jailed scope에서만 생기는 일이다. machine-wide에서는 `relative`가
152        // 절대경로를 그대로 주고, scope 밖이면 `name_of`가 원시 표기로
153        // 떨어지므로 어느 쪽이든 비지 않는다 — 이 갈래에 오지 않는다.
154        "<unreadable entry>".to_string()
155    } else {
156        format!("{parent_name}/<unreadable entry>")
157    }
158}
159
160/// API가 이 경로를 부르는 이름. scope 바깥이면 원시 표기로 떨어진다 —
161/// 실패 목록에 이름을 붙이는 것이 목적이므로 여기서 거부할 일은 아니다.
162fn 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        // app, app/a.txt, app/deep, app/deep/b.txt
191        assert_eq!(outcome.removed, 4);
192        assert_eq!(outcome.bytes, 4, "두 파일 × 2바이트");
193        assert!(outcome.failures.is_empty());
194        // 응답이 아니라 디스크로 확인한다: "안 지웠다"는 응답만으로 증명되지 않는다.
195        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    /// 세는 것은 싸고 나르는 것은 비싸다 — 개수는 정확하고 목록만 잘린다.
212    #[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    /// 테스트에서 심볼릭 링크를 만든다. 실패는 `io::Result`로 그대로 넘길
225    /// 뿐 여기서 관용하지 않는다 — 어떻게 다룰지는 `require_symlink`가
226    /// 정한다. `tests/fs_api.rs`와 `src/fs/root.rs`의 test 모듈에 있는 같은
227    /// 이름의 헬퍼를 그대로 본뜬 것 — 테스트 모듈 경계를 넘는 공유보다 이
228    /// 리포의 기존 방식(중복)에 맞춘다.
229    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    /// `try_symlink`와 같되 대상이 디렉터리일 때. Windows는 파일 심링크와
248    /// 디렉터리 심링크를 생성 시점에 구분한다(`symlink_file` vs
249    /// `symlink_dir`); Unix는 구분하지 않는다.
250    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    /// 열거 중 실패한 엔트리는 조용히 사라지지 않는다.
269    ///
270    /// `read_dir` 이터레이터가 `Err`을 내도록 플랫폼 독립적으로 유도할 방법이
271    /// 없어(`readdir`/`FindNextFileW`의 중간 실패는 임의로 만들 수 없다),
272    /// 이터레이터가 실제로 내놓는 것과 **같은 타입**을 `visit_entry`에 직접
273    /// 건넨다. 결정 로직은 진짜 프로덕션 코드다. 다만 `Err`을 여기까지 실어
274    /// 나르는 `visit` 쪽 배선 한 줄은 이 테스트가 덮지 못한다 — 보고서에
275    /// 그대로 적었다.
276    #[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        // 세지 않는 것이 맞다: 이름도 크기도 모르는 것을 세면 미리보기가
293        // 반대 방향으로 거짓말한다. 호출자는 `failures`를 보고 판단한다.
294        assert_eq!(out.removed, 0);
295        assert_eq!(out.bytes, 0);
296        assert!(out.entries.is_empty());
297    }
298
299    /// 부모가 jail 루트 자신이면 `relative`가 빈 문자열을 주므로, 그대로 이으면
300    /// `/<unreadable entry>`가 되어 절대경로처럼 읽힌다.
301    #[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    /// 링크를 만들지 못했으면 **플랫폼 구분 없이** 실패한다. 스킵 경로는
320    /// 없다.
321    ///
322    /// 조용한 스킵은 `#[ignore]`보다 못하다. libtest는 통과한 테스트를 요약에
323    /// `ok`로 적으므로 스킵을 `eprintln!`으로 표시해도 그 줄이 통과와 구별되지
324    /// 않고, 게다가 통과한 테스트의 출력은 캡처해 버려 표시 자체가 보이지
325    /// 않는다. 이 리포의 CI는 `cargo test --all --features relay-client
326    /// --verbose`로 도는데 `--verbose`는 cargo의 빌드 로그 플래그일 뿐
327    /// libtest 캡처와 무관하다(`--show-output`이라야 나온다). 즉 스킵한
328    /// 러너와 실제로 검증한 러너가 CI 로그에서 똑같아 보인다. `#[ignore]`는
329    /// 적어도 요약에 카운트를 남긴다.
330    ///
331    /// 심링크를 만들 수 없는 환경이라면 해법은 그 권한을 부여하는 것이지
332    /// 테스트를 침묵시키는 것이 아니다(Windows: 개발자 모드 또는
333    /// `SeCreateSymbolicLinkPrivilege`). 심링크를 지원하지 않는 플랫폼
334    /// (`try_symlink`의 `#[cfg(not(any(unix, windows)))]` 갈래)도 마찬가지로
335    /// 조용히 넘어가지 않고 실패한다 — CI 매트릭스는 ubuntu/windows/macos뿐이라
336    /// 닿지 않지만, 닿는다면 그 사실을 알아야 한다.
337    ///
338    /// 원인을 추측하지 않도록 `io::Error`를 패닉 메시지에 싣는다.
339    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    /// 링크를 따라가면 트리 밖을 지운다.
346    #[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    /// 위 파일-심링크 테스트는 이 성질을 전혀 검증하지 않는다: 링크가 파일을
366    /// 가리키면 `unlink`은 링크 자체만 끊으므로, `symlink_metadata`를
367    /// `metadata`로 바꿔 링크를 따라가게 만들어도 `is_dir()`은 여전히
368    /// false이고 결과는 똑같이 통과한다. 위험은 **디렉터리** 심링크다 — 따라
369    /// 들어가면 `is_dir()`이 참이 되어 `read_dir`이 대상 디렉터리 안으로
370    /// 내려가 그 내용물을 지운다. Windows도 예외가 아니다: 디렉터리 reparse
371    /// point도 `metadata`로 보면 디렉터리로 보이므로 같은 메커니즘이 재현된다.
372    #[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        // 미리보기도 링크를 하나의 항목으로만 센다 — 대상 안으로 내려가면
388        // 개수가 부풀어 호출자가 "얼마나 큰 일인가"를 오판하게 된다.
389        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}