From 9a69606d68244a78ecf78deea6437d7aa8c03744 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 13:10:10 +0900 Subject: [PATCH 01/18] test: expose symlinked cache-root cleanup escape --- src-tauri/src/rules.rs | 98 ++++++++++++++++++++++++++---------------- 1 file changed, 60 insertions(+), 38 deletions(-) diff --git a/src-tauri/src/rules.rs b/src-tauri/src/rules.rs index 2fa6cebbf..ae0c1663b 100644 --- a/src-tauri/src/rules.rs +++ b/src-tauri/src/rules.rs @@ -14,9 +14,6 @@ impl BaseDirs { let home = std::env::var(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).ok()?; let home = PathBuf::from(home); let temp = std::env::temp_dir(); - // 플랫폼별 분기를 #[cfg]로 걸어 각 타겟 빌드에 다른 쪽 arm이 아예 존재하지 않게 한다 - // (런타임 cfg!()였다면 리눅스 게이트에서 windows arm이 컴파일은 되지만 죽은 채로 남아 - // 라인 커버리지 갭이 된다 — catalog()의 npm/pip와 동일한 이유) #[cfg(windows)] let local_data = std::env::var("LOCALAPPDATA").map(PathBuf::from).ok()?; #[cfg(not(windows))] @@ -34,45 +31,48 @@ pub struct CacheCandidate { pub exists: bool, } -/// 정적 캐시 카탈로그 (스펙 §4 rules). 항목 = (id, 라벨, 베이스 기준 상대경로). -/// ponytail: 브라우저 캐시는 프로필 글롭이 필요해 M2 범위 밖 — 카탈로그에 추가만 하면 확장됨 fn catalog(bases: &BaseDirs) -> Vec<(&'static str, &'static str, PathBuf)> { - // #[cfg]로 걸어 각 타겟 빌드엔 자신의 arm만 존재 — cfg!()런타임 분기였다면 리눅스 게이트에서 - // windows/macOS arm이 컴파일은 되지만 죽은 채로 남아 라인 커버리지 갭이 된다 #[cfg(windows)] let npm = bases.local_data.join("npm-cache"); #[cfg(not(windows))] - let npm = bases.home.join(".npm"); // npm 실제 기본값 (linux/macOS) + let npm = bases.home.join(".npm"); #[cfg(windows)] let pip = bases.local_data.join("pip").join("cache"); #[cfg(target_os = "macos")] let pip = bases.home.join("Library").join("Caches").join("pip"); #[cfg(not(any(windows, target_os = "macos")))] - let pip = bases.local_data.join("pip"); // linux: ~/.cache/pip + let pip = bases.local_data.join("pip"); - // Windows 전용 진단/트레이스 캐시는 아래 extend로 추가 — 다른 플랫폼선 그 라인이 cfg-absent라 - // mut가 미사용이므로 allow(unused_mut). (npm/pip와 같은 cfg 규율) #[allow(unused_mut)] let mut entries = vec![ ("os-temp", "OS 임시 폴더", bases.temp.clone()), ("npm-cache", "npm 캐시", npm), ("pip-cache", "pip 캐시", pip), - ("cargo-registry-cache", "cargo 레지스트리 캐시", - bases.home.join(".cargo").join("registry").join("cache")), + ( + "cargo-registry-cache", + "cargo 레지스트리 캐시", + bases.home.join(".cargo").join("registry").join("cache"), + ), ]; - // Windows 진단 캐시 — 조용히 수십 GB로 자라는 것들. RDP 자동 추적(RdClientAutoTrace)의 .etl 로그가 - // 대표적: 원격 접속 세션마다 쌓여 재발하므로, os-temp에 묻어두지 않고 명명 항목으로 노출해 - // 사용자가 크기를 보고 그것만 콕 집어 정리하게 한다. WER/CrashDumps도 동류의 진단 산출물. #[cfg(windows)] entries.extend([ - ("rdp-autotrace", "원격 데스크톱 추적 로그", - bases.temp.join("DiagOutputDir").join("RdClientAutoTrace")), - ("windows-crashdumps", "앱 크래시 덤프", - bases.local_data.join("CrashDumps")), - ("windows-wer", "Windows 오류 보고 (WER)", - bases.local_data.join("Microsoft").join("Windows").join("WER")), + ( + "rdp-autotrace", + "원격 데스크톱 추적 로그", + bases.temp.join("DiagOutputDir").join("RdClientAutoTrace"), + ), + ( + "windows-crashdumps", + "앱 크래시 덤프", + bases.local_data.join("CrashDumps"), + ), + ( + "windows-wer", + "Windows 오류 보고 (WER)", + bases.local_data.join("Microsoft").join("Windows").join("WER"), + ), ]); entries @@ -84,10 +84,6 @@ pub fn cache_candidates(bases: &BaseDirs) -> Vec { .map(|(id, label, path)| { let exists = path.is_dir(); let bytes = if exists { - // ponytail: 규칙별 블로킹 스캔(취소 불가) — os-temp가 거대하면 느릴 수 있음. - // UX가 문제 되면 candidates에 취소 토큰과 진행 이벤트를 추가. - // interval 1: 진행 콜백(no-op)이 작은 테스트 픽스처에서도 실행되어 커버리지에서 - // 0으로 남지 않음 — 콜백이 아무 일도 하지 않으므로 호출 빈도는 동작에 무관 scanner::scan_dir_with_interval(&path, &AtomicBool::new(false), 1, |_| {}).stats.bytes } else { 0 @@ -103,15 +99,14 @@ pub fn cache_candidates(bases: &BaseDirs) -> Vec { .collect() } -/// dir이 현재 카탈로그가 가리키는 경로인지 (expand_clean_targets의 스코프 검증용 — 크기 계산 없음) pub fn is_catalog_path(bases: &BaseDirs, dir: &Path) -> bool { catalog(bases).iter().any(|(_, _, p)| p == dir) } -/// 캐시 디렉토리 자체는 보존하고 내용물만 비우기 위한 직계 자식 열거. -/// 심링크는 제외 — 이 코드베이스의 모든 순회와 동일한 방어 (scanner keep_entry, node_view 참조) pub fn clean_targets(dir: &Path) -> Vec { - let Ok(rd) = std::fs::read_dir(dir) else { return Vec::new() }; + let Ok(rd) = std::fs::read_dir(dir) else { + return Vec::new(); + }; rd.filter_map(|e| e.ok()) .filter(|e| e.file_type().map(|t| !t.is_symlink()).unwrap_or(false)) .map(|e| e.path()) @@ -133,8 +128,6 @@ mod tests { #[test] fn from_env_uses_real_environment() { - // 데스크톱 앱은 항상 사용자 세션에서 실행되므로 HOME/USERPROFILE·LOCALAPPDATA는 - // 테스트 러너에도 항상 설정돼 있다 (win/linux 공통) assert!(BaseDirs::from_env().is_some()); } @@ -142,28 +135,27 @@ mod tests { fn catalog_reports_sizes_and_existence() { let tmp = tempfile::tempdir().unwrap(); let bases = fake_bases(tmp.path()); - // npm 캐시만 실제로 만들어 둔다 (한 줄: 각 arm이 별도 라인이면 플랫폼별로 반대쪽이 - // 영구 미커버로 남는다 — is_protected의 home 변수명 선택과 동일한 관례) - let npm = if cfg!(windows) { bases.local_data.join("npm-cache") } else { bases.home.join(".npm") }; + let npm = if cfg!(windows) { + bases.local_data.join("npm-cache") + } else { + bases.home.join(".npm") + }; fs::create_dir_all(&npm).unwrap(); fs::write(npm.join("blob.bin"), vec![0u8; 128]).unwrap(); let cands = cache_candidates(&bases); - let npm_c = cands.iter().find(|c| c.id == "npm-cache").unwrap(); assert!(npm_c.exists); assert_eq!(npm_c.bytes, 128); let temp_c = cands.iter().find(|c| c.id == "os-temp").unwrap(); assert!(!temp_c.exists); assert_eq!(temp_c.bytes, 0); - // 카탈로그에 최소 4개 규칙 assert!(cands.len() >= 4); } #[cfg(windows)] #[test] fn catalog_includes_windows_diagnostic_caches() { - // RDP 추적/크래시 덤프/WER를 명명 항목으로 노출 — extend arm 커버(Windows) let tmp = tempfile::tempdir().unwrap(); let bases = fake_bases(tmp.path()); let cands = cache_candidates(&bases); @@ -216,4 +208,34 @@ mod tests { .collect(); assert_eq!(names, vec!["real.bin"]); } + + #[cfg(unix)] + #[test] + fn catalog_scope_rejects_symlinked_cache_root() { + let tmp = tempfile::tempdir().unwrap(); + let outside = tmp.path().join("outside"); + fs::create_dir(&outside).unwrap(); + let linked_cache = tmp.path().join("linked-cache"); + std::os::unix::fs::symlink(&outside, &linked_cache).unwrap(); + let bases = BaseDirs { + temp: linked_cache.clone(), + local_data: tmp.path().join("local"), + home: tmp.path().join("home"), + }; + + assert!(!is_catalog_path(&bases, &linked_cache)); + } + + #[cfg(unix)] + #[test] + fn clean_targets_rejects_symlinked_cache_root() { + let tmp = tempfile::tempdir().unwrap(); + let outside = tmp.path().join("outside"); + fs::create_dir(&outside).unwrap(); + fs::write(outside.join("keep.bin"), b"keep").unwrap(); + let linked_cache = tmp.path().join("linked-cache"); + std::os::unix::fs::symlink(&outside, &linked_cache).unwrap(); + + assert!(clean_targets(&linked_cache).is_empty()); + } } From 38346fb76933a7672654fa4249699c1bb5bdc43a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 13:12:26 +0900 Subject: [PATCH 02/18] fix: fail closed on symlinked cache roots --- src-tauri/src/rules.rs | 99 +++++++++++++++++++++++++++++------------- 1 file changed, 68 insertions(+), 31 deletions(-) diff --git a/src-tauri/src/rules.rs b/src-tauri/src/rules.rs index ae0c1663b..2a81e881f 100644 --- a/src-tauri/src/rules.rs +++ b/src-tauri/src/rules.rs @@ -14,6 +14,9 @@ impl BaseDirs { let home = std::env::var(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).ok()?; let home = PathBuf::from(home); let temp = std::env::temp_dir(); + // 플랫폼별 분기를 #[cfg]로 걸어 각 타겟 빌드에 다른 쪽 arm이 아예 존재하지 않게 한다 + // (런타임 cfg!()였다면 리눅스 게이트에서 windows arm이 컴파일은 되지만 죽은 채로 남아 + // 라인 커버리지 갭이 된다 — catalog()의 npm/pip와 동일한 이유) #[cfg(windows)] let local_data = std::env::var("LOCALAPPDATA").map(PathBuf::from).ok()?; #[cfg(not(windows))] @@ -31,59 +34,79 @@ pub struct CacheCandidate { pub exists: bool, } +/// 정적 캐시 카탈로그 (스펙 §4 rules). 항목 = (id, 라벨, 베이스 기준 상대경로). +/// ponytail: 브라우저 캐시는 프로필 글롭이 필요해 M2 범위 밖 — 카탈로그에 추가만 하면 확장됨 fn catalog(bases: &BaseDirs) -> Vec<(&'static str, &'static str, PathBuf)> { + // #[cfg]로 걸어 각 타겟 빌드엔 자신의 arm만 존재 — cfg!()런타임 분기였다면 리눅스 게이트에서 + // windows/macOS arm이 컴파일은 되지만 죽은 채로 남아 라인 커버리지 갭이 된다 #[cfg(windows)] let npm = bases.local_data.join("npm-cache"); #[cfg(not(windows))] - let npm = bases.home.join(".npm"); + let npm = bases.home.join(".npm"); // npm 실제 기본값 (linux/macOS) #[cfg(windows)] let pip = bases.local_data.join("pip").join("cache"); #[cfg(target_os = "macos")] let pip = bases.home.join("Library").join("Caches").join("pip"); #[cfg(not(any(windows, target_os = "macos")))] - let pip = bases.local_data.join("pip"); + let pip = bases.local_data.join("pip"); // linux: ~/.cache/pip + // Windows 전용 진단/트레이스 캐시는 아래 extend로 추가 — 다른 플랫폼선 그 라인이 cfg-absent라 + // mut가 미사용이므로 allow(unused_mut). (npm/pip와 같은 cfg 규율) #[allow(unused_mut)] let mut entries = vec![ ("os-temp", "OS 임시 폴더", bases.temp.clone()), ("npm-cache", "npm 캐시", npm), ("pip-cache", "pip 캐시", pip), - ( - "cargo-registry-cache", - "cargo 레지스트리 캐시", - bases.home.join(".cargo").join("registry").join("cache"), - ), + ("cargo-registry-cache", "cargo 레지스트리 캐시", + bases.home.join(".cargo").join("registry").join("cache")), ]; + // Windows 진단 캐시 — 조용히 수십 GB로 자라는 것들. RDP 자동 추적(RdClientAutoTrace)의 .etl 로그가 + // 대표적: 원격 접속 세션마다 쌓여 재발하므로, os-temp에 묻어두지 않고 명명 항목으로 노출해 + // 사용자가 크기를 보고 그것만 콕 집어 정리하게 한다. WER/CrashDumps도 동류의 진단 산출물. #[cfg(windows)] entries.extend([ - ( - "rdp-autotrace", - "원격 데스크톱 추적 로그", - bases.temp.join("DiagOutputDir").join("RdClientAutoTrace"), - ), - ( - "windows-crashdumps", - "앱 크래시 덤프", - bases.local_data.join("CrashDumps"), - ), - ( - "windows-wer", - "Windows 오류 보고 (WER)", - bases.local_data.join("Microsoft").join("Windows").join("WER"), - ), + ("rdp-autotrace", "원격 데스크톱 추적 로그", + bases.temp.join("DiagOutputDir").join("RdClientAutoTrace")), + ("windows-crashdumps", "앱 크래시 덤프", + bases.local_data.join("CrashDumps")), + ("windows-wer", "Windows 오류 보고 (WER)", + bases.local_data.join("Microsoft").join("Windows").join("WER")), ]); entries } +/// 정리 권한과 스캔 경계에 쓰는 카탈로그 루트는 실제 디렉터리여야 하며, +/// 루트 자체가 심링크/Windows reparse point이면 fail-closed 한다. 자식 심링크만 +/// 거르는 것으로는 `read_dir(root_symlink)`가 바깥 디렉터리를 따라가는 것을 막을 수 없다. +fn is_real_directory(path: &Path) -> bool { + let Ok(metadata) = std::fs::symlink_metadata(path) else { return false }; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return false; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return false; + } + } + true +} + pub fn cache_candidates(bases: &BaseDirs) -> Vec { catalog(bases) .into_iter() .map(|(id, label, path)| { - let exists = path.is_dir(); + let exists = is_real_directory(&path); let bytes = if exists { + // ponytail: 규칙별 블로킹 스캔(취소 불가) — os-temp가 거대하면 느릴 수 있음. + // UX가 문제 되면 candidates에 취소 토큰과 진행 이벤트를 추가. + // interval 1: 진행 콜백(no-op)이 작은 테스트 픽스처에서도 실행되어 커버리지에서 + // 0으로 남지 않음 — 콜백이 아무 일도 하지 않으므로 호출 빈도는 동작에 무관 scanner::scan_dir_with_interval(&path, &AtomicBool::new(false), 1, |_| {}).stats.bytes } else { 0 @@ -99,14 +122,18 @@ pub fn cache_candidates(bases: &BaseDirs) -> Vec { .collect() } +/// dir이 현재 카탈로그가 가리키는 경로인지 (expand_clean_targets의 스코프 검증용 — 크기 계산 없음) pub fn is_catalog_path(bases: &BaseDirs, dir: &Path) -> bool { - catalog(bases).iter().any(|(_, _, p)| p == dir) + is_real_directory(dir) && catalog(bases).iter().any(|(_, _, p)| p == dir) } +/// 캐시 디렉토리 자체는 보존하고 내용물만 비우기 위한 직계 자식 열거. +/// 루트 자체와 직계 자식 심링크를 모두 제외 — 카탈로그 경계 밖 디렉터리를 따라가지 않는다. pub fn clean_targets(dir: &Path) -> Vec { - let Ok(rd) = std::fs::read_dir(dir) else { + if !is_real_directory(dir) { return Vec::new(); - }; + } + let Ok(rd) = std::fs::read_dir(dir) else { return Vec::new() }; rd.filter_map(|e| e.ok()) .filter(|e| e.file_type().map(|t| !t.is_symlink()).unwrap_or(false)) .map(|e| e.path()) @@ -128,6 +155,8 @@ mod tests { #[test] fn from_env_uses_real_environment() { + // 데스크톱 앱은 항상 사용자 세션에서 실행되므로 HOME/USERPROFILE·LOCALAPPDATA는 + // 테스트 러너에도 항상 설정돼 있다 (win/linux 공통) assert!(BaseDirs::from_env().is_some()); } @@ -135,27 +164,28 @@ mod tests { fn catalog_reports_sizes_and_existence() { let tmp = tempfile::tempdir().unwrap(); let bases = fake_bases(tmp.path()); - let npm = if cfg!(windows) { - bases.local_data.join("npm-cache") - } else { - bases.home.join(".npm") - }; + // npm 캐시만 실제로 만들어 둔다 (한 줄: 각 arm이 별도 라인이면 플랫폼별로 반대쪽이 + // 영구 미커버로 남는다 — is_protected의 home 변수명 선택과 동일한 관례) + let npm = if cfg!(windows) { bases.local_data.join("npm-cache") } else { bases.home.join(".npm") }; fs::create_dir_all(&npm).unwrap(); fs::write(npm.join("blob.bin"), vec![0u8; 128]).unwrap(); let cands = cache_candidates(&bases); + let npm_c = cands.iter().find(|c| c.id == "npm-cache").unwrap(); assert!(npm_c.exists); assert_eq!(npm_c.bytes, 128); let temp_c = cands.iter().find(|c| c.id == "os-temp").unwrap(); assert!(!temp_c.exists); assert_eq!(temp_c.bytes, 0); + // 카탈로그에 최소 4개 규칙 assert!(cands.len() >= 4); } #[cfg(windows)] #[test] fn catalog_includes_windows_diagnostic_caches() { + // RDP 추적/크래시 덤프/WER를 명명 항목으로 노출 — extend arm 커버(Windows) let tmp = tempfile::tempdir().unwrap(); let bases = fake_bases(tmp.path()); let cands = cache_candidates(&bases); @@ -171,6 +201,7 @@ mod tests { fn is_catalog_path_scopes_to_catalog() { let tmp = tempfile::tempdir().unwrap(); let bases = fake_bases(tmp.path()); + fs::create_dir(&bases.temp).unwrap(); assert!(is_catalog_path(&bases, &bases.temp)); assert!(!is_catalog_path(&bases, tmp.path())); } @@ -224,6 +255,12 @@ mod tests { }; assert!(!is_catalog_path(&bases, &linked_cache)); + let candidate = cache_candidates(&bases) + .into_iter() + .find(|candidate| candidate.id == "os-temp") + .unwrap(); + assert!(!candidate.exists); + assert_eq!(candidate.bytes, 0); } #[cfg(unix)] From 0156cb3d4f21e6da9d743c20131c852a3762eb4a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 15:49:08 +0900 Subject: [PATCH 03/18] fix: bind cache scans to opened directory handles --- src-tauri/src/rules.rs | 219 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 192 insertions(+), 27 deletions(-) diff --git a/src-tauri/src/rules.rs b/src-tauri/src/rules.rs index 2a81e881f..f1a542e14 100644 --- a/src-tauri/src/rules.rs +++ b/src-tauri/src/rules.rs @@ -1,7 +1,6 @@ use std::path::{Path, PathBuf}; -use std::sync::atomic::AtomicBool; -use crate::scanner; +use same_file::Handle; pub struct BaseDirs { pub temp: PathBuf, @@ -78,11 +77,7 @@ fn catalog(bases: &BaseDirs) -> Vec<(&'static str, &'static str, PathBuf)> { entries } -/// 정리 권한과 스캔 경계에 쓰는 카탈로그 루트는 실제 디렉터리여야 하며, -/// 루트 자체가 심링크/Windows reparse point이면 fail-closed 한다. 자식 심링크만 -/// 거르는 것으로는 `read_dir(root_symlink)`가 바깥 디렉터리를 따라가는 것을 막을 수 없다. -fn is_real_directory(path: &Path) -> bool { - let Ok(metadata) = std::fs::symlink_metadata(path) else { return false }; +fn metadata_is_real_directory(metadata: &std::fs::Metadata) -> bool { if !metadata.is_dir() || metadata.file_type().is_symlink() { return false; } @@ -97,20 +92,170 @@ fn is_real_directory(path: &Path) -> bool { true } +fn path_is_real_directory(path: &Path) -> bool { + std::fs::symlink_metadata(path) + .map(|metadata| metadata_is_real_directory(&metadata)) + .unwrap_or(false) +} + +#[cfg(windows)] +fn open_directory_handle(path: &Path) -> Option { + use std::os::windows::fs::OpenOptionsExt; + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + + let file = std::fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS) + .open(path) + .ok()?; + Handle::from_file(file).ok() +} + +#[cfg(not(windows))] +fn open_directory_handle(path: &Path) -> Option { + Handle::from_path(path).ok() +} + +#[cfg(target_os = "linux")] +fn handle_namespace_path(handle: &Handle, _display_path: &Path) -> Option { + use std::os::fd::AsRawFd; + Some(PathBuf::from(format!( + "/proc/self/fd/{}", + handle.as_file().as_raw_fd() + ))) +} + +#[cfg(target_os = "macos")] +fn handle_namespace_path(handle: &Handle, _display_path: &Path) -> Option { + use std::os::fd::AsRawFd; + Some(PathBuf::from(format!( + "/dev/fd/{}", + handle.as_file().as_raw_fd() + ))) +} + +#[cfg(windows)] +fn handle_namespace_path(_handle: &Handle, display_path: &Path) -> Option { + Some(display_path.to_path_buf()) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +fn handle_namespace_path(_handle: &Handle, _display_path: &Path) -> Option { + None +} + +/// 카탈로그 루트의 경로명과 열린 디렉터리 핸들을 한 번의 권한 경계로 묶는다. +/// Unix에서는 이후 I/O를 열린 fd namespace 경로로 수행하므로 원래 경로가 rename/symlink로 +/// 교체돼도 다른 디렉터리로 리다이렉트되지 않는다. Windows에서는 DELETE 공유를 제외한 +/// 디렉터리 핸들을 유지해 같은 기간 루트 rename/delete 교체를 차단한다. +struct CatalogRoot { + handle: Handle, + display_path: PathBuf, +} + +impl CatalogRoot { + fn open(path: &Path) -> Option { + // 1차 lstat: 명시적 symlink/reparse root를 즉시 거부. + if !path_is_real_directory(path) { + return None; + } + + // 경로를 연 뒤 다시 lstat+open하고 두 핸들의 파일 ID를 비교한다. 이 순서로 + // 검사 중 경로가 바뀌는 check/use 경합도 fail-closed 한다. + let handle = open_directory_handle(path)?; + if !path_is_real_directory(path) { + return None; + } + let current = open_directory_handle(path)?; + if handle != current { + return None; + } + + Some(Self { + handle, + display_path: path.to_path_buf(), + }) + } + + fn stable_path(&self) -> Option { + let stable = handle_namespace_path(&self.handle, &self.display_path)?; + let expected = Handle::from_file(self.handle.as_file().try_clone().ok()?).ok()?; + #[cfg(windows)] + let observed = open_directory_handle(&stable)?; + #[cfg(not(windows))] + let observed = Handle::from_path(&stable).ok()?; + (expected == observed).then_some(stable) + } + + fn directory_size(&self) -> u64 { + let Some(stable) = self.stable_path() else { return 0 }; + let Ok(entries) = std::fs::read_dir(stable) else { return 0 }; + let mut bytes = 0u64; + + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + let Ok(metadata) = std::fs::symlink_metadata(&path) else { continue }; + if metadata.file_type().is_symlink() { + continue; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + continue; + } + } + + if metadata.is_file() { + bytes = bytes.saturating_add(metadata.len()); + } else if metadata.is_dir() { + if let Some(child) = CatalogRoot::open(&path) { + bytes = bytes.saturating_add(child.directory_size()); + } + } + } + + bytes + } + + fn child_paths(&self) -> Vec { + let Some(stable) = self.stable_path() else { return Vec::new() }; + let Ok(entries) = std::fs::read_dir(stable) else { return Vec::new() }; + + entries + .filter_map(Result::ok) + .filter_map(|entry| { + let stable_child = entry.path(); + let metadata = std::fs::symlink_metadata(&stable_child).ok()?; + if metadata.file_type().is_symlink() { + return None; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return None; + } + } + Some(self.display_path.join(entry.file_name())) + }) + .collect() + } +} + pub fn cache_candidates(bases: &BaseDirs) -> Vec { catalog(bases) .into_iter() .map(|(id, label, path)| { - let exists = is_real_directory(&path); - let bytes = if exists { - // ponytail: 규칙별 블로킹 스캔(취소 불가) — os-temp가 거대하면 느릴 수 있음. - // UX가 문제 되면 candidates에 취소 토큰과 진행 이벤트를 추가. - // interval 1: 진행 콜백(no-op)이 작은 테스트 픽스처에서도 실행되어 커버리지에서 - // 0으로 남지 않음 — 콜백이 아무 일도 하지 않으므로 호출 빈도는 동작에 무관 - scanner::scan_dir_with_interval(&path, &AtomicBool::new(false), 1, |_| {}).stats.bytes - } else { - 0 - }; + let root = CatalogRoot::open(&path); + let exists = root.is_some(); + let bytes = root.as_ref().map(CatalogRoot::directory_size).unwrap_or(0); CacheCandidate { id: id.into(), label: label.into(), @@ -124,20 +269,15 @@ pub fn cache_candidates(bases: &BaseDirs) -> Vec { /// dir이 현재 카탈로그가 가리키는 경로인지 (expand_clean_targets의 스코프 검증용 — 크기 계산 없음) pub fn is_catalog_path(bases: &BaseDirs, dir: &Path) -> bool { - is_real_directory(dir) && catalog(bases).iter().any(|(_, _, p)| p == dir) + catalog(bases).iter().any(|(_, _, p)| p == dir) && CatalogRoot::open(dir).is_some() } /// 캐시 디렉토리 자체는 보존하고 내용물만 비우기 위한 직계 자식 열거. -/// 루트 자체와 직계 자식 심링크를 모두 제외 — 카탈로그 경계 밖 디렉터리를 따라가지 않는다. +/// 루트는 열린 핸들에 고정하고 직계 자식 symlink/reparse point도 제외한다. pub fn clean_targets(dir: &Path) -> Vec { - if !is_real_directory(dir) { - return Vec::new(); - } - let Ok(rd) = std::fs::read_dir(dir) else { return Vec::new() }; - rd.filter_map(|e| e.ok()) - .filter(|e| e.file_type().map(|t| !t.is_symlink()).unwrap_or(false)) - .map(|e| e.path()) - .collect() + CatalogRoot::open(dir) + .map(|root| root.child_paths()) + .unwrap_or_default() } #[cfg(test)] @@ -275,4 +415,29 @@ mod tests { assert!(clean_targets(&linked_cache).is_empty()); } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn opened_catalog_root_cannot_be_redirected_by_path_replacement() { + let tmp = tempfile::tempdir().unwrap(); + let catalog = tmp.path().join("catalog"); + let moved = tmp.path().join("catalog-original"); + let outside = tmp.path().join("outside"); + fs::create_dir(&catalog).unwrap(); + fs::create_dir(&outside).unwrap(); + fs::write(catalog.join("inside.bin"), vec![0u8; 7]).unwrap(); + fs::write(outside.join("outside.bin"), vec![0u8; 101]).unwrap(); + + let root = CatalogRoot::open(&catalog).expect("catalog root should open"); + fs::rename(&catalog, &moved).unwrap(); + std::os::unix::fs::symlink(&outside, &catalog).unwrap(); + + assert_eq!(root.directory_size(), 7); + let names: Vec = root + .child_paths() + .iter() + .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) + .collect(); + assert_eq!(names, vec!["inside.bin"]); + } } From 420da096c4a326ddd2fa878aff6a6ecf5d3cd50e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 16:22:13 +0900 Subject: [PATCH 04/18] security: open cache roots without following symlinks --- src-tauri/src/rules.rs | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/rules.rs b/src-tauri/src/rules.rs index f1a542e14..6e8dfccdf 100644 --- a/src-tauri/src/rules.rs +++ b/src-tauri/src/rules.rs @@ -115,9 +115,26 @@ fn open_directory_handle(path: &Path) -> Option { Handle::from_file(file).ok() } -#[cfg(not(windows))] +#[cfg(target_os = "linux")] +const NOFOLLOW_DIRECTORY_FLAGS: i32 = 0o600000; // O_DIRECTORY | O_NOFOLLOW +#[cfg(target_os = "macos")] +const NOFOLLOW_DIRECTORY_FLAGS: i32 = 0x0010_0100; // O_DIRECTORY | O_NOFOLLOW + +#[cfg(any(target_os = "linux", target_os = "macos"))] fn open_directory_handle(path: &Path) -> Option { - Handle::from_path(path).ok() + use std::os::unix::fs::OpenOptionsExt; + + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(NOFOLLOW_DIRECTORY_FLAGS) + .open(path) + .ok()?; + Handle::from_file(file).ok() +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +fn open_directory_handle(_path: &Path) -> Option { + None } #[cfg(target_os = "linux")] @@ -380,6 +397,18 @@ mod tests { assert_eq!(names, vec!["real.bin"]); } + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn unix_directory_handle_open_rejects_symlink() { + let tmp = tempfile::tempdir().unwrap(); + let real = tmp.path().join("real"); + let linked = tmp.path().join("linked"); + fs::create_dir(&real).unwrap(); + std::os::unix::fs::symlink(&real, &linked).unwrap(); + + assert!(open_directory_handle(&linked).is_none()); + } + #[cfg(unix)] #[test] fn catalog_scope_rejects_symlinked_cache_root() { From 7f44a703a779a3bb5d00b375754fd9450f2a34c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 17:08:31 +0900 Subject: [PATCH 05/18] test: bind cache cleanup execution boundary --- src/lib/cacheCleanupFlowContract.test.ts | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/lib/cacheCleanupFlowContract.test.ts diff --git a/src/lib/cacheCleanupFlowContract.test.ts b/src/lib/cacheCleanupFlowContract.test.ts new file mode 100644 index 000000000..d23794d78 --- /dev/null +++ b/src/lib/cacheCleanupFlowContract.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +function readSource(path: string): string { + return readFileSync(resolve(repositoryRoot, path), "utf8"); +} + +describe("cache cleanup execution boundary", () => { + it("uses one backend operation for cache cleanup", () => { + const cleanup = readSource("src/lib/Cleanup.svelte"); + const api = readSource("src/lib/api.ts"); + const commands = readSource("src-tauri/src/commands.rs"); + const tauri = readSource("src-tauri/src/lib.rs"); + + expect(cleanup).not.toContain("api.expandCleanTargets(c.path)"); + expect(cleanup).toContain("api.cleanCacheContents(c.path)"); + expect(api).toContain('invoke("clean_cache_contents"'); + expect(commands).toContain("pub fn clean_cache_contents("); + expect(tauri).toContain("commands::clean_cache_contents"); + }); +}); From 20bcd666a08692a2466e8cd44a7a60e3c70f09e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 17:12:10 +0900 Subject: [PATCH 06/18] fix: bind cache cleanup to one root authority --- src-tauri/src/cache_cleanup.rs | 260 +++++++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 src-tauri/src/cache_cleanup.rs diff --git a/src-tauri/src/cache_cleanup.rs b/src-tauri/src/cache_cleanup.rs new file mode 100644 index 000000000..3a67d3ac2 --- /dev/null +++ b/src-tauri/src/cache_cleanup.rs @@ -0,0 +1,260 @@ +use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicBool; + +use same_file::Handle; + +use crate::{commands::CleanResult, rules, safety, scanner}; + +#[cfg(windows)] +fn open_directory_handle(path: &Path) -> Option { + use std::os::windows::fs::OpenOptionsExt; + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + + let file = std::fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS) + .open(path) + .ok()?; + Handle::from_file(file).ok() +} + +#[cfg(target_os = "linux")] +const NOFOLLOW_DIRECTORY_FLAGS: i32 = 0o600000; // O_DIRECTORY | O_NOFOLLOW +#[cfg(target_os = "macos")] +const NOFOLLOW_DIRECTORY_FLAGS: i32 = 0x0010_0100; // O_DIRECTORY | O_NOFOLLOW + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn open_directory_handle(path: &Path) -> Option { + use std::os::unix::fs::OpenOptionsExt; + + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(NOFOLLOW_DIRECTORY_FLAGS) + .open(path) + .ok()?; + Handle::from_file(file).ok() +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +fn open_directory_handle(_path: &Path) -> Option { + None +} + +#[cfg(target_os = "linux")] +fn handle_namespace_path(handle: &Handle, _display_path: &Path) -> PathBuf { + use std::os::fd::AsRawFd; + PathBuf::from(format!("/proc/self/fd/{}", handle.as_file().as_raw_fd())) +} + +#[cfg(target_os = "macos")] +fn handle_namespace_path(handle: &Handle, _display_path: &Path) -> PathBuf { + use std::os::fd::AsRawFd; + PathBuf::from(format!("/dev/fd/{}", handle.as_file().as_raw_fd())) +} + +#[cfg(windows)] +fn handle_namespace_path(_handle: &Handle, display_path: &Path) -> PathBuf { + display_path.to_path_buf() +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +fn handle_namespace_path(_handle: &Handle, display_path: &Path) -> PathBuf { + display_path.to_path_buf() +} + +struct CacheRootGuard { + handle: Handle, + display_path: PathBuf, +} + +impl CacheRootGuard { + fn open(bases: &rules::BaseDirs, path: &Path) -> Option { + if !rules::is_catalog_path(bases, path) { + return None; + } + let handle = open_directory_handle(path)?; + let guard = Self { + handle, + display_path: path.to_path_buf(), + }; + guard.still_current().then_some(guard) + } + + fn still_current(&self) -> bool { + open_directory_handle(&self.display_path) + .is_some_and(|current| current == self.handle) + } + + fn targets(&self) -> Option> { + let stable_root = handle_namespace_path(&self.handle, &self.display_path); + let entries = std::fs::read_dir(stable_root).ok()?; + let mut targets = Vec::new(); + for entry in entries.filter_map(Result::ok) { + let io_path = entry.path(); + let metadata = std::fs::symlink_metadata(&io_path).ok()?; + if metadata.file_type().is_symlink() { + continue; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + continue; + } + } + targets.push((io_path, self.display_path.join(entry.file_name()))); + } + Some(targets) + } +} + +fn target_bytes(path: &Path) -> u64 { + if path.is_dir() { + scanner::scan_dir_with_interval(path, &AtomicBool::new(false), 1, |_| {}) + .stats + .bytes + } else { + path.metadata().map(|metadata| metadata.len()).unwrap_or(0) + } +} + +fn failure(path: &Path, error: &str) -> CleanResult { + CleanResult { + path: path.to_string_lossy().into_owned(), + ok: false, + error: error.into(), + } +} + +fn clean_cache_contents_inner( + bases: &rules::BaseDirs, + dir: &Path, + journal_path: &Path, + now_ms: u64, +) -> Result, String> { + let guard = CacheRootGuard::open(bases, dir).ok_or("cache-root-not-current-or-safe")?; + let targets = guard.targets().ok_or("cache-root-enumeration-failed")?; + let mut results = Vec::with_capacity(targets.len()); + + for (io_path, display_path) in targets { + let bytes = target_bytes(&io_path); + if !guard.still_current() { + results.push(failure(&display_path, "cache-root-changed-before-delete")); + break; + } + results.push(match safety::trash_delete(&display_path, bytes, journal_path, now_ms) { + Ok(()) => CleanResult { + path: display_path.to_string_lossy().into_owned(), + ok: true, + error: String::new(), + }, + Err(error) => failure(&display_path, &error.to_string()), + }); + } + + Ok(results) +} + +/// Empty one approved cache directory while retaining a no-follow handle to the validated root. +/// The command never accepts arbitrary roots and stops before the next deletion if the catalog +/// path no longer resolves to the exact directory handle established for this operation. +#[cfg(not(coverage))] +#[tauri::command] +pub fn clean_cache_contents(dir: String, app: tauri::AppHandle) -> Result, String> { + use tauri::Manager; + + let bases = rules::BaseDirs::from_env().ok_or("cache-base-directories-unavailable")?; + let journal_dir = app.path().app_data_dir().map_err(|error| error.to_string())?; + std::fs::create_dir_all(&journal_dir).map_err(|error| error.to_string())?; + let journal_path = journal_dir.join("journal.jsonl"); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0); + clean_cache_contents_inner(&bases, Path::new(&dir), &journal_path, now_ms) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn fake_bases(root: &Path) -> rules::BaseDirs { + rules::BaseDirs { + temp: root.join("cache"), + local_data: root.join("local"), + home: root.join("home"), + } + } + + #[test] + fn guard_lists_only_real_children_under_catalog_root() { + let tmp = tempfile::tempdir().unwrap(); + let bases = fake_bases(tmp.path()); + fs::create_dir(&bases.temp).unwrap(); + fs::write(bases.temp.join("real.bin"), b"abc").unwrap(); + let guard = CacheRootGuard::open(&bases, &bases.temp).unwrap(); + let targets = guard.targets().unwrap(); + assert_eq!(targets.len(), 1); + assert!(targets[0].0.exists()); + assert_eq!(targets[0].1, bases.temp.join("real.bin")); + assert!(guard.still_current()); + } + + #[cfg(unix)] + #[test] + fn guard_detects_catalog_root_replacement() { + let tmp = tempfile::tempdir().unwrap(); + let bases = fake_bases(tmp.path()); + let moved = tmp.path().join("cache-original"); + let outside = tmp.path().join("outside"); + fs::create_dir(&bases.temp).unwrap(); + fs::create_dir(&outside).unwrap(); + let guard = CacheRootGuard::open(&bases, &bases.temp).unwrap(); + fs::rename(&bases.temp, &moved).unwrap(); + std::os::unix::fs::symlink(&outside, &bases.temp).unwrap(); + assert!(!guard.still_current()); + } + + #[test] + fn cleanup_rejects_non_catalog_root() { + let tmp = tempfile::tempdir().unwrap(); + let bases = fake_bases(tmp.path()); + fs::create_dir(&bases.temp).unwrap(); + let journal = tmp.path().join("journal.jsonl"); + let result = clean_cache_contents_inner(&bases, tmp.path(), &journal, 1); + assert_eq!(result.unwrap_err(), "cache-root-not-current-or-safe"); + } + + #[cfg(any(windows, target_os = "linux"))] + #[test] + fn cleanup_moves_catalog_children_to_trash_and_preserves_root() { + let tmp = tempfile::tempdir().unwrap(); + let bases = fake_bases(tmp.path()); + fs::create_dir(&bases.temp).unwrap(); + let victim = bases.temp.join("disksage-cache-cleanup-fixture.bin"); + fs::write(&victim, vec![0u8; 12]).unwrap(); + let journal = tmp.path().join("journal.jsonl"); + + let results = clean_cache_contents_inner(&bases, &bases.temp, &journal, 7).unwrap(); + assert_eq!(results.len(), 1); + assert!(results[0].ok); + assert!(!victim.exists()); + assert!(bases.temp.exists()); + let recent = safety::journal_recent(&journal, 2); + assert_eq!(recent[0].path, victim.to_string_lossy()); + assert_eq!(recent[0].bytes, 12); + + let items: Vec<_> = trash::os_limited::list() + .unwrap() + .into_iter() + .filter(|item| item.name.to_string_lossy().contains("disksage-cache-cleanup-fixture")) + .collect(); + trash::os_limited::purge_all(items).unwrap(); + } +} From 19be3e660e2e62120620baf4366a496f5d360f82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 17:12:39 +0900 Subject: [PATCH 07/18] fix: register atomic cache cleanup command --- src-tauri/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f98a44243..7fac379c5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,8 @@ mod dupes; #[cfg_attr(coverage, allow(dead_code))] mod commands; #[cfg_attr(coverage, allow(dead_code))] +mod cache_cleanup; +#[cfg_attr(coverage, allow(dead_code))] mod scanner; #[cfg_attr(coverage, allow(dead_code))] mod userrules; @@ -88,6 +90,7 @@ pub fn run() { commands::list_cache_candidates, commands::list_dev_artifacts, commands::clean_paths, + cache_cleanup::clean_cache_contents, commands::recent_operations, commands::expand_clean_targets, commands::find_duplicate_files, From a6682333d83e41beeeb0a37a1c806a9ce9638acc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 17:13:29 +0900 Subject: [PATCH 08/18] fix: keep cache cleanup inside backend boundary --- src/lib/Cleanup.svelte | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/lib/Cleanup.svelte b/src/lib/Cleanup.svelte index eceb302ec..f99c386df 100644 --- a/src/lib/Cleanup.svelte +++ b/src/lib/Cleanup.svelte @@ -1,5 +1,6 @@