diff --git a/src-tauri/src/cache_cleanup.rs b/src-tauri/src/cache_cleanup.rs new file mode 100644 index 000000000..3e43bb39d --- /dev/null +++ b/src-tauri/src/cache_cleanup.rs @@ -0,0 +1,94 @@ +use std::path::Path; + +use crate::{commands::CleanResult, rules}; + +const ATOMIC_TRASH_UNAVAILABLE: &str = "cache-cleanup-atomic-trash-unavailable"; + +fn clean_cache_contents_inner( + bases: &rules::BaseDirs, + dir: &Path, +) -> Result, String> { + if !rules::is_catalog_path(bases, dir) { + return Err("cache-root-not-current-or-safe".into()); + } + + // A path-based recycle-bin API cannot preserve the identity of a child entry across the + // final same-user rename/symlink race on every supported desktop platform. Re-validating the + // root immediately before a path-based delete still leaves a check/use window. Until the + // recycle operation itself is bound to the validated filesystem object, refuse cache + // mutation instead of risking moving an unrelated path to the trash. + Err(ATOMIC_TRASH_UNAVAILABLE.into()) +} + +/// Validate an approved cache root and fail closed until DiskSage has a recycle operation that is +/// bound to the exact validated filesystem object. Read-only cache discovery remains available; +/// this command deliberately grants no destructive authority while path identity can race. +#[cfg(not(coverage))] +#[tauri::command] +pub fn clean_cache_contents(dir: String) -> Result, String> { + let bases = rules::BaseDirs::from_env().ok_or("cache-base-directories-unavailable")?; + clean_cache_contents_inner(&bases, Path::new(&dir)) +} + +#[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 cleanup_rejects_non_catalog_root() { + let tmp = tempfile::tempdir().unwrap(); + let bases = fake_bases(tmp.path()); + fs::create_dir(&bases.temp).unwrap(); + + let error = clean_cache_contents_inner(&bases, tmp.path()) + .err() + .expect("non-catalog root should be rejected"); + + assert_eq!(error, "cache-root-not-current-or-safe"); + } + + #[test] + fn cleanup_refuses_mutation_until_target_identity_can_be_preserved() { + let tmp = tempfile::tempdir().unwrap(); + let bases = fake_bases(tmp.path()); + fs::create_dir(&bases.temp).unwrap(); + let victim = bases.temp.join("keep.bin"); + fs::write(&victim, b"keep").unwrap(); + + let error = clean_cache_contents_inner(&bases, &bases.temp) + .err() + .expect("path-based cache cleanup must fail closed"); + + assert_eq!(error, ATOMIC_TRASH_UNAVAILABLE); + assert_eq!(fs::read(&victim).unwrap(), b"keep"); + assert!(bases.temp.is_dir()); + } + + #[cfg(unix)] + #[test] + fn cleanup_rejects_symlinked_catalog_root_without_touching_outside_data() { + let tmp = tempfile::tempdir().unwrap(); + let bases = fake_bases(tmp.path()); + let outside = tmp.path().join("outside"); + fs::create_dir(&outside).unwrap(); + let outside_file = outside.join("outside.bin"); + fs::write(&outside_file, b"outside").unwrap(); + std::os::unix::fs::symlink(&outside, &bases.temp).unwrap(); + + let error = clean_cache_contents_inner(&bases, &bases.temp) + .err() + .expect("symlinked catalog root should be rejected"); + + assert_eq!(error, "cache-root-not-current-or-safe"); + assert_eq!(fs::read(&outside_file).unwrap(), b"outside"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f98a44243..6dd64cb87 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,9 +1,14 @@ +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +compile_error!("DiskSage supports only Windows, Linux, and macOS targets."); + // coverage 빌드(비-테스트)에서는 run()이 빠져 모듈 내용이 테스트에서만 쓰이므로 dead_code만 허용 #[cfg_attr(coverage, allow(dead_code))] 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 +93,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, diff --git a/src-tauri/src/rules.rs b/src-tauri/src/rules.rs index 2fa6cebbf..6e8dfccdf 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,20 +77,202 @@ fn catalog(bases: &BaseDirs) -> Vec<(&'static str, &'static str, PathBuf)> { entries } +fn metadata_is_real_directory(metadata: &std::fs::Metadata) -> bool { + 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 +} + +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(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) -> 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 = 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 - }; + 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(), @@ -105,17 +286,15 @@ pub fn cache_candidates(bases: &BaseDirs) -> Vec { /// dir이 현재 카탈로그가 가리키는 경로인지 (expand_clean_targets의 스코프 검증용 — 크기 계산 없음) pub fn is_catalog_path(bases: &BaseDirs, dir: &Path) -> bool { - catalog(bases).iter().any(|(_, _, p)| p == dir) + catalog(bases).iter().any(|(_, _, p)| p == dir) && CatalogRoot::open(dir).is_some() } /// 캐시 디렉토리 자체는 보존하고 내용물만 비우기 위한 직계 자식 열거. -/// 심링크는 제외 — 이 코드베이스의 모든 순회와 동일한 방어 (scanner keep_entry, node_view 참조) +/// 루트는 열린 핸들에 고정하고 직계 자식 symlink/reparse point도 제외한다. pub fn clean_targets(dir: &Path) -> Vec { - 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)] @@ -179,6 +358,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())); } @@ -216,4 +396,77 @@ mod tests { .collect(); 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() { + 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)); + let candidate = cache_candidates(&bases) + .into_iter() + .find(|candidate| candidate.id == "os-temp") + .unwrap(); + assert!(!candidate.exists); + assert_eq!(candidate.bytes, 0); + } + + #[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()); + } + + #[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"]); + } } diff --git a/src/lib/Cleanup.svelte b/src/lib/Cleanup.svelte index eceb302ec..fcd59b632 100644 --- a/src/lib/Cleanup.svelte +++ b/src/lib/Cleanup.svelte @@ -10,7 +10,6 @@ let caches: api.CacheCandidate[] = $state([]); let artifacts: api.DevArtifact[] = $state([]); let selected: Set = $state(new Set()); - let selectedRules: Set = $state(new Set()); let results: api.CleanResult[] = $state([]); let busy = $state(false); let loadError = $state(""); @@ -44,28 +43,19 @@ } let totalSelected = $derived( - caches.filter((c) => selectedRules.has(c.id)).reduce((s, c) => s + c.bytes, 0) + - artifacts.filter((a) => selected.has(a.path)).reduce((s, a) => s + a.bytes, 0), + artifacts.filter((a) => selected.has(a.path)).reduce((sum, artifact) => sum + artifact.bytes, 0), ); - let selectionCount = $derived( - caches.filter((c) => selectedRules.has(c.id) && c.exists).length + - artifacts.filter((a) => selected.has(a.path)).length, - ); + let selectionCount = $derived(artifacts.filter((a) => selected.has(a.path)).length); async function executeClean() { // 검토·확인 (스펙 §7-6): 명시적 승인 없이는 아무것도 실행되지 않는다 - const ruleDirs = caches.filter((c) => selectedRules.has(c.id) && c.exists); const artifactPaths = artifacts.filter((a) => selected.has(a.path)).map((a) => a.path); - const summary = [ - ...ruleDirs.map((c) => `${c.label} (${fmtBytes(c.bytes)}) — 내용물 비우기`), - ...artifactPaths, - ]; - if (summary.length === 0) return; + if (artifactPaths.length === 0) return; const okay = await confirm( - `다음 ${summary.length}개 항목을 휴지통으로 보냅니다 (논리 크기 합계 ${fmtBytes(totalSelected)}):\n\n` + - summary.slice(0, 15).join("\n") + - (summary.length > 15 ? `\n… 외 ${summary.length - 15}개` : "") + + `다음 ${artifactPaths.length}개 항목을 휴지통으로 보냅니다 (논리 크기 합계 ${fmtBytes(totalSelected)}):\n\n` + + artifactPaths.slice(0, 15).join("\n") + + (artifactPaths.length > 15 ? `\n… 외 ${artifactPaths.length - 15}개` : "") + "\n\n휴지통에서 언제든 복원할 수 있습니다. 휴지통을 비우기 전에는 물리 공간이 회수되지 않으며, APFS 공유 블록 때문에 실제 회수량은 논리 크기보다 작을 수 있습니다.", { title: "DiskSage", kind: "warning" }, ); @@ -73,13 +63,8 @@ busy = true; try { - const paths: string[] = [...artifactPaths]; - for (const c of ruleDirs) { - paths.push(...(await api.expandCleanTargets(c.path))); - } - results = await api.cleanPaths(paths); + results = await api.cleanPaths(artifactPaths); selected = new Set(); - selectedRules = new Set(); await load(); } catch (e) { loadError = String(e); @@ -93,19 +78,17 @@

정리

- {#if loadError}

{loadError}

{/if} + {#if loadError}{/if}

캐시

+

+ 캐시 항목은 현재 읽기 전용입니다. 검증된 파일시스템 객체와 휴지통 이동을 하나의 원자적 권한 경계로 묶기 전까지 DiskSage는 캐시 삭제를 실행하지 않습니다. +

    {#each caches as c (c.id)}
  • -