diff --git a/README.md b/README.md index 755b922bc..27a20c45e 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ ## Safety first -Every destructive action goes through explicit review and the OS trash — DiskSage has **no permanent-delete code path**. Cloud archiving currently exposes copy and evidence only: even a successful provider attestation returns a local-eviction permit without deleting the source. All destructive operations are journaled and undoable. +Every destructive action goes through explicit review and the OS trash — DiskSage has **no permanent-delete code path**. Developer-artifact selections carry a bounded, metadata-only fingerprint, byte/file counts, scan status, and a platform filesystem-object identity; the Rust command re-scans immediately before trashing, atomically stages the exact identity in a private sibling directory, and rejects changed, recreated, unreadable, or incomplete candidates. Cloud archiving currently exposes copy and evidence only: even a successful provider attestation returns a local-eviction permit without deleting the source. All destructive operations are journaled and sent to OS trash; identity-staged operations retain their private recovery directory so OS-trash undo has a valid staged target, while restoring to the original path remains a separate recovery step. The headless split-archive audit is read-only. A contiguous sequence does not invent proof that its last observed member is the terminal part, and a missing-part result is never automatic deletion diff --git a/docs/superpowers/specs/2026-07-21-apfs-reclaim-evidence-design.md b/docs/superpowers/specs/2026-07-21-apfs-reclaim-evidence-design.md index 617b9bfdf..3f7f8c0c6 100644 --- a/docs/superpowers/specs/2026-07-21-apfs-reclaim-evidence-design.md +++ b/docs/superpowers/specs/2026-07-21-apfs-reclaim-evidence-design.md @@ -15,6 +15,14 @@ reports: - `physically_reclaimable_bytes: null` and `status: unverified` before the operation; - stable reason codes explaining shared-extent uncertainty and Trash retention. +When an operator needs to distinguish an idle cache from a build or editor tree that is currently +in use, the command accepts `--check-active-use`. This opt-in adds bounded, path-local `lsof` +evidence per normalized root (`evidence_complete`, `active`, and a capped PID list). Regular-file +roots use an exact-file `lsof` query (`lsof-file-pid`), while directory roots use recursive +`lsof` (`lsof-recursive-pid`). The probe is diagnostic only and never treats an idle result as +permission to delete. The default output omits this optional field for compatibility and to avoid +the extra process/file scan. + Nested selected roots are deduplicated and symbolic-link roots are rejected. The command never moves, unlinks, or writes to supplied paths. APFS clone sharing is intentionally not inferred from content equality or per-inode allocated blocks because those are not proof of unique extents or @@ -26,3 +34,13 @@ reparse points are included in `skipped` rather than silently disappearing from The GUI must label selection totals as logical size. Moving an item to Trash preserves its blocks; actual physical recovery can only be claimed from a post-lifecycle filesystem free-space observation after Trash is emptied or from an equally strong filesystem-native unique-extent proof. + +For example, a read-only cache review can be run with: + +```sh +cargo run --locked --manifest-path src-tauri/Cargo.toml \ + --bin disksage-reclaim-plan -- \ + --operation trash --check-active-use \ + "$HOME/Library/Caches/codec-carver" \ + "$HOME/Library/Caches/trivy" +``` diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bc7978063..339002966 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1058,6 +1058,7 @@ dependencies = [ "unicode-general-category", "unicode-normalization", "ureq", + "winapi-util", "zeroize", "zip", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a08f3e266..594ae62fa 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -151,6 +151,9 @@ infer = "0.22.0" png = "0.17.16" mail-parser = "0.11.5" +[target.'cfg(windows)'.dependencies] +winapi-util = "0.1.11" + [target.'cfg(target_os = "macos")'.dependencies] embed_plist = "1.2.2" objc2 = "0.6.4" diff --git a/src-tauri/src/bin/disksage-reclaim-plan.rs b/src-tauri/src/bin/disksage-reclaim-plan.rs index efd93e99b..35cac5e4c 100644 --- a/src-tauri/src/bin/disksage-reclaim-plan.rs +++ b/src-tauri/src/bin/disksage-reclaim-plan.rs @@ -3,11 +3,11 @@ //! The parser preserves operating-system paths without forcing Unicode conversion. The command //! produces local evidence only and never moves, deletes, or otherwise mutates supplied paths. -use disksage_lib::reclaim::{plan_reclaim, PlannedOperation}; +use disksage_lib::reclaim::{plan_reclaim_with_options, PlannedOperation, ReclaimPlanOptions}; use std::ffi::OsString; use std::path::PathBuf; -const USAGE: &str = "Usage: disksage-reclaim-plan [--operation trash|delete] [--pretty] PATH...\n\ +const USAGE: &str = "Usage: disksage-reclaim-plan [--operation trash|delete] [--pretty] [--check-active-use] PATH...\n\ Builds read-only logical/allocation evidence. It never moves or deletes files."; /// Parsed arguments for one reclaim-plan execution. @@ -17,6 +17,8 @@ struct Args { operation: PlannedOperation, /// Whether the JSON result should use human-readable indentation. pretty: bool, + /// Whether to include bounded process/file-use evidence for each root. + check_active_use: bool, /// Filesystem roots to inspect without mutation. paths: Vec, } @@ -34,6 +36,7 @@ enum ParseResult { fn parse_args(raw_args: impl IntoIterator) -> Result { let mut operation = PlannedOperation::Trash; let mut pretty = false; + let mut check_active_use = false; let mut paths = Vec::new(); let mut args = raw_args.into_iter(); @@ -49,6 +52,7 @@ fn parse_args(raw_args: impl IntoIterator) -> Result pretty = true, + Some("--check-active-use") => check_active_use = true, Some("-h" | "--help") => return Ok(ParseResult::Help), Some("--") => { paths.extend(args.map(PathBuf::from)); @@ -64,6 +68,7 @@ fn parse_args(raw_args: impl IntoIterator) -> Result) -> Result<(), Str } ParseResult::Run(args) => args, }; - let plan = plan_reclaim(&args.paths, args.operation)?; + let plan = plan_reclaim_with_options( + &args.paths, + args.operation, + ReclaimPlanOptions { + include_active_use: args.check_active_use, + }, + )?; let json = if args.pretty { serde_json::to_string_pretty(&plan) } else { @@ -119,6 +130,7 @@ mod tests { OsString::from("--operation"), OsString::from("delete"), OsString::from("--pretty"), + OsString::from("--check-active-use"), OsString::from("/tmp/example"), ]) .unwrap(), @@ -126,17 +138,14 @@ mod tests { assert_eq!(parsed.operation, PlannedOperation::Delete); assert!(parsed.pretty); + assert!(parsed.check_active_use); assert_eq!(parsed.paths, [PathBuf::from("/tmp/example")]); } #[test] fn double_dash_preserves_option_like_paths() { let parsed = expect_run( - parse_args([ - OsString::from("--"), - OsString::from("--not-an-option"), - ]) - .unwrap(), + parse_args([OsString::from("--"), OsString::from("--not-an-option")]).unwrap(), ); assert_eq!(parsed.paths, [PathBuf::from("--not-an-option")]); diff --git a/src-tauri/src/cloud.rs b/src-tauri/src/cloud.rs index 28b04e14d..45eb87fc3 100644 --- a/src-tauri/src/cloud.rs +++ b/src-tauri/src/cloud.rs @@ -50,6 +50,10 @@ const MAX_INCOMPLETE_DOWNLOAD_EOCD_OFFSETS: usize = 64; const MAX_EMAIL_HEADER_BYTES: usize = 1024 * 1024; #[cfg(not(coverage))] const MAX_AUDACITY_SCHEMA_PROBE_BYTES: usize = 64 * 1024; +#[cfg(all(not(coverage), target_os = "macos"))] +const DIRECTORY_READ_TIMEOUT: Duration = Duration::from_secs(3); +#[cfg(all(not(coverage), target_os = "macos"))] +const DIRECTORY_READ_OUTPUT_LIMIT: u64 = 256 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "kebab-case")] @@ -318,6 +322,16 @@ pub fn validate_cloud_root_readable(root: &CloudRoot) -> Result<(), String> { root.access_issue.as_deref().unwrap_or("not-verified") )); } + + #[cfg(all(not(coverage), target_os = "macos"))] + { + if let Some(reason) = directory_access_issue(Path::new(&root.path)) { + return Err(format!("cloud-root-unreadable:{}:{reason}", root.path)); + } + return Ok(()); + } + + #[cfg(any(coverage, not(target_os = "macos")))] std::fs::read_dir(&root.path) .map(|_| ()) .map_err(|error| format!("cloud-root-unreadable:{}:{error}", root.path)) @@ -358,24 +372,134 @@ fn access_issue_for_error(error: &std::io::Error) -> String { #[cfg(not(coverage))] fn directory_access_issue(path: &Path) -> Option { + #[cfg(all(not(coverage), target_os = "macos"))] + { + return run_bounded_find( + path, + &["-mindepth", "1", "-maxdepth", "1", "-print0", "-quit"], + ) + .err(); + } + + #[cfg(any(coverage, not(target_os = "macos")))] std::fs::read_dir(path) .err() .map(|error| access_issue_for_error(&error)) } +#[cfg(all(not(coverage), target_os = "macos"))] +fn run_bounded_find(path: &Path, action: &[&str]) -> Result, String> { + let metadata = std::fs::metadata(path).map_err(|error| access_issue_for_error(&error))?; + if !metadata.is_dir() { + return Err("not-a-directory".into()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mode = metadata.permissions().mode(); + if mode & 0o444 == 0 || mode & 0o111 == 0 { + return Err("permission-denied".into()); + } + } + + let find = Path::new("/usr/bin/find"); + let find_metadata = + std::fs::symlink_metadata(find).map_err(|_| "read-dir-helper-unavailable".to_string())?; + if !find_metadata.file_type().is_file() { + return Err("read-dir-helper-unavailable".into()); + } + + let mut child = Command::new(find) + .arg(path) + .args(action) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|_| "read-dir-helper-failed".to_string())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "read-dir-helper-failed".to_string())?; + let reader = std::thread::spawn(move || { + let mut output = Vec::new(); + stdout + .take(DIRECTORY_READ_OUTPUT_LIMIT + 1) + .read_to_end(&mut output) + .map(|_| output) + }); + + let deadline = Instant::now() + DIRECTORY_READ_TIMEOUT; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(10)); + } + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = reader.join(); + return Err("read-dir-timeout".into()); + } + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = reader.join(); + return Err("read-dir-helper-failed".into()); + } + } + }; + let output = reader + .join() + .map_err(|_| "read-dir-helper-failed".to_string())? + .map_err(|_| "read-dir-helper-failed".to_string())?; + if output.len() as u64 > DIRECTORY_READ_OUTPUT_LIMIT { + return Err("read-dir-output-too-large".into()); + } + if !status.success() { + return Err("read-dir-failed".into()); + } + Ok(output) +} + #[cfg(not(coverage))] fn read_children_sorted(path: &Path, limit: usize) -> Result, String> { - let entries = std::fs::read_dir(path).map_err(|error| access_issue_for_error(&error))?; - let mut children = Vec::new(); - for entry in entries.take(limit) { - children.push( - entry - .map_err(|error| access_issue_for_error(&error))? - .path(), - ); + #[cfg(target_os = "macos")] + { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let output = run_bounded_find(path, &["-mindepth", "1", "-maxdepth", "1", "-print0"])?; + let mut children = Vec::new(); + for raw in output + .split(|byte| *byte == 0) + .filter(|raw| !raw.is_empty()) + { + if children.len() >= limit { + break; + } + children.push(PathBuf::from(OsString::from_vec(raw.to_vec()))); + } + children.sort(); + return Ok(children); + } + + #[cfg(not(target_os = "macos"))] + { + let entries = std::fs::read_dir(path).map_err(|error| access_issue_for_error(&error))?; + let mut children = Vec::new(); + for entry in entries.take(limit) { + children.push( + entry + .map_err(|error| access_issue_for_error(&error))? + .path(), + ); + } + children.sort(); + Ok(children) } - children.sort(); - Ok(children) } #[cfg(not(coverage))] diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3bbf97923..2b2a4db2d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -137,6 +137,64 @@ pub fn clean_paths_inner(paths: &[PathBuf], journal_path: &Path, now_ms: u64) -> .collect() } +/// 개발 아티팩트는 목록 시점의 bounded metadata manifest와 일치할 때만 휴지통으로 보낸다. +/// 선택 후 재생성·변경된 target/node_modules는 경로가 같아도 재스캔을 요구한다. +pub fn clean_dev_artifacts_inner( + requests: &[dev_artifacts::DevArtifact], + root: &Path, + min_age_days: u64, + journal_path: &Path, + now_ms: u64, +) -> Vec { + let current = dev_artifacts::find_artifacts(root, min_age_days, now_ms); + requests + .iter() + .map(|request| { + let matches = current.iter().find(|candidate| { + candidate.path == request.path + && candidate.kind == request.kind + && candidate.project == request.project + && candidate.bytes == request.bytes + && candidate.files == request.files + && candidate.skipped == request.skipped + && candidate.scan_complete + && request.scan_complete + && request.skipped == 0 + && candidate.fingerprint == request.fingerprint + && !request.object_id.is_empty() + && candidate.object_id == request.object_id + && candidate.age_days >= request.age_days + }); + if matches.is_none() { + return CleanResult { + path: request.path.clone(), + ok: false, + error: "개발 아티팩트가 변경되었거나 메타데이터 스캔이 불완전합니다. 정리 전에 다시 스캔하세요".into(), + }; + } + + match safety::trash_delete_if_identity( + Path::new(&request.path), + &request.object_id, + request.bytes, + journal_path, + now_ms, + ) { + Ok(()) => CleanResult { + path: request.path.clone(), + ok: true, + error: String::new(), + }, + Err(error) => CleanResult { + path: request.path.clone(), + ok: false, + error: error.to_string(), + }, + } + }) + .collect() +} + /// 저널의 move 경로 필드 "src -> dst"를 분리 (순수 함수 — 테스트 대상). 구분자 없으면 None. pub fn parse_move_entry(path_field: &str) -> Option<(String, String)> { path_field @@ -424,6 +482,24 @@ pub fn clean_paths(paths: Vec, app: AppHandle) -> Result, + app: AppHandle, +) -> Result, String> { + let jp = journal_file_path(&app)?; + Ok(clean_dev_artifacts_inner( + &artifacts, + Path::new(&root), + min_age_days, + &jp, + now_ms(), + )) +} + #[cfg(not(coverage))] #[tauri::command] pub fn recent_operations( @@ -2163,6 +2239,38 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . } } + #[test] + fn dev_artifact_cleanup_rejects_a_stale_metadata_fingerprint() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("webapp"); + let artifact = project.join("node_modules"); + fs::create_dir_all(&artifact).unwrap(); + fs::write(project.join("package.json"), b"{}").unwrap(); + fs::write(artifact.join("payload.bin"), b"old").unwrap(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + let observed = crate::dev_artifacts::find_artifacts(tmp.path(), 0, now); + assert_eq!(observed.len(), 1); + + // The path still exists, but its metadata manifest no longer matches the selection. + fs::write(artifact.join("payload.bin"), b"recreated-with-different-size").unwrap(); + let results = clean_dev_artifacts_inner( + &observed, + tmp.path(), + 0, + &tmp.path().join("journal.jsonl"), + now, + ); + + assert_eq!(results.len(), 1); + assert!(!results[0].ok); + assert!(results[0].error.contains("다시 스캔")); + assert!(artifact.join("payload.bin").exists()); + } + #[test] fn execute_moves_inner_reports_per_item_and_isolates_failures() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/dev_artifacts.rs b/src-tauri/src/dev_artifacts.rs index 9d70123a2..2066ad11a 100644 --- a/src-tauri/src/dev_artifacts.rs +++ b/src-tauri/src/dev_artifacts.rs @@ -1,14 +1,28 @@ use std::path::{Path, PathBuf}; -use std::sync::atomic::AtomicBool; +use std::time::{Duration, Instant}; use crate::scanner; -#[derive(Debug, Clone, serde::Serialize)] +// A development tree can contain millions of generated entries. The inventory remains +// fail-closed for cleanup when this bounded metadata manifest cannot finish; it must never turn +// a partial observation into permission to move a recreated directory to the trash. +const ARTIFACT_MANIFEST_BUDGET: Duration = Duration::from_secs(3); +const ARTIFACT_MANIFEST_MAX_RECORDS: usize = 250_000; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct DevArtifact { pub path: String, pub kind: String, pub project: String, pub bytes: u64, + pub files: u64, + pub skipped: u64, + pub scan_complete: bool, + /// Deterministic metadata manifest; file contents are never read. + pub fingerprint: String, + /// Platform filesystem identity of the candidate root; unlike a path it cannot be reused by + /// a recreated directory on Unix/Windows. + pub object_id: String, pub age_days: u64, } @@ -33,6 +47,127 @@ fn age_days(path: &Path, now_ms: u64) -> u64 { now_ms.saturating_sub(mtime_ms) / 86_400_000 } +#[derive(Default)] +struct ArtifactManifest { + bytes: u64, + files: u64, + skipped: u64, + scan_complete: bool, + records: Vec, + fingerprint: String, + object_id: String, +} + +/// Build a bounded, deterministic metadata-only manifest for one generated directory. +/// +/// Paths, kinds, sizes, mtimes, and symlink targets are enough to detect a stale selection while +/// avoiding sensitive content reads. A time/record bound makes the cleanup gate fail closed on +/// unusually large trees instead of blocking the UI indefinitely. +fn artifact_manifest(root: &Path) -> ArtifactManifest { + let mut manifest = ArtifactManifest { + scan_complete: true, + ..ArtifactManifest::default() + }; + let root_object_id = crate::safety::filesystem_object_id(root).ok(); + if root_object_id.is_none() { + manifest.scan_complete = false; + } + manifest.object_id = root_object_id.unwrap_or_default(); + let deadline = Instant::now() + ARTIFACT_MANIFEST_BUDGET; + let walker = jwalk::WalkDir::new(root) + .follow_links(false) + .skip_hidden(false) + .process_read_dir(|_depth, _path, _state, children| { + children.retain(|r| r.as_ref().map(scanner::keep_entry).unwrap_or(true)); + }); + + for entry in walker { + if Instant::now() >= deadline || manifest.records.len() >= ARTIFACT_MANIFEST_MAX_RECORDS { + manifest.scan_complete = false; + break; + } + let Ok(entry) = entry else { + manifest.skipped = manifest.skipped.saturating_add(1); + manifest.scan_complete = false; + continue; + }; + if entry.read_children_error.is_some() { + manifest.skipped = manifest.skipped.saturating_add(1); + manifest.scan_complete = false; + } + let entry_path = entry.path(); + let relative = entry_path + .strip_prefix(root) + .unwrap_or(entry_path.as_path()) + .to_string_lossy() + .replace('\\', "/"); + let relative = if relative.is_empty() { "." } else { &relative }; + let file_type = entry.file_type(); + if file_type.is_dir() { + let Ok(metadata) = entry.metadata() else { + manifest.skipped = manifest.skipped.saturating_add(1); + manifest.scan_complete = false; + continue; + }; + let identity = crate::safety::filesystem_object_id(&entry_path).unwrap_or_else(|_| { + manifest.skipped = manifest.skipped.saturating_add(1); + manifest.scan_complete = false; + "".into() + }); + let modified = modified_stamp(&metadata).unwrap_or_else(|| { + manifest.skipped = manifest.skipped.saturating_add(1); + manifest.scan_complete = false; + "".into() + }); + manifest + .records + .push(format!("D\0{relative}\0{identity}\0{modified}")); + } else if file_type.is_file() { + let Ok(metadata) = entry.metadata() else { + manifest.skipped = manifest.skipped.saturating_add(1); + manifest.scan_complete = false; + continue; + }; + let identity = crate::safety::filesystem_object_id(&entry_path).unwrap_or_else(|_| { + manifest.skipped = manifest.skipped.saturating_add(1); + manifest.scan_complete = false; + "".into() + }); + let modified = modified_stamp(&metadata).unwrap_or_else(|| { + manifest.skipped = manifest.skipped.saturating_add(1); + manifest.scan_complete = false; + "".into() + }); + manifest.bytes = manifest.bytes.saturating_add(metadata.len()); + manifest.files = manifest.files.saturating_add(1); + manifest + .records + .push(format!("F\0{relative}\0{identity}\0{}\0{modified}", metadata.len())); + } + } + + if !manifest.scan_complete { + manifest.records.push("!incomplete\0bounded-artifact-manifest".into()); + } + manifest.records.sort_unstable(); + manifest.fingerprint = metadata_fingerprint(&manifest.records); + manifest +} + +fn modified_stamp(metadata: &std::fs::Metadata) -> Option { + let duration = metadata.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?; + Some(format!("{}:{}", duration.as_secs(), duration.subsec_nanos())) +} + +fn metadata_fingerprint(records: &[String]) -> String { + let mut hasher = blake3::Hasher::new(); + for record in records { + hasher.update(&(record.len() as u64).to_le_bytes()); + hasher.update(record.as_bytes()); + } + hasher.finalize().to_hex().to_string() +} + /// 마커 인접 아티팩트 디렉토리를 찾아 mtime 나이로 걸러 크기 내림차순으로 반환. /// /// 2패스로 나눈 이유: jwalk는 병렬로 디렉토리를 순회해 부모/자식 방문 순서를 @@ -89,9 +224,7 @@ pub fn find_artifacts(root: &Path, min_age_days: u64, now_ms: u64) -> Vec Vec GitWorktreeActiveUseEvidence { } #[cfg(unix)] -fn active_use_evidence( +pub(crate) fn active_use_evidence( path: &Path, timeout_ms: u64, max_pids: usize, + recursive: bool, ) -> GitWorktreeActiveUseEvidence { // Running lsof with its own CWD inside the audited tree would make the probe observe itself. // A canonical worktree has an existing parent, which is outside the candidate directory. let command_cwd = path.parent().unwrap_or(path); - let result = match run_bounded_command( - "lsof", - &[ - OsString::from("-F0p"), - OsString::from("+D"), - path.as_os_str().to_os_string(), - ], - command_cwd, - timeout_ms, - ) { + let method = if recursive { + "lsof-recursive-pid" + } else { + "lsof-file-pid" + }; + let mut lsof_args = vec![OsString::from("-F0p")]; + if recursive { + lsof_args.push(OsString::from("+D")); + } + lsof_args.push(path.as_os_str().to_os_string()); + let result = match run_bounded_command("lsof", &lsof_args, command_cwd, timeout_ms) { Ok(result) => result, Err(error) => { return GitWorktreeActiveUseEvidence { - method: "lsof-recursive-pid".into(), + method: method.into(), assessed: true, evidence_complete: false, active: false, @@ -637,7 +639,7 @@ fn active_use_evidence( }; if result.timed_out { return GitWorktreeActiveUseEvidence { - method: "lsof-recursive-pid".into(), + method: method.into(), assessed: true, evidence_complete: false, active: false, @@ -648,7 +650,7 @@ fn active_use_evidence( } if result.stdout_truncated || result.stderr_truncated { return GitWorktreeActiveUseEvidence { - method: "lsof-recursive-pid".into(), + method: method.into(), assessed: true, evidence_complete: false, active: false, @@ -662,7 +664,7 @@ fn active_use_evidence( || (result.status_code == Some(1) && !stderr.trim().is_empty()) { return GitWorktreeActiveUseEvidence { - method: "lsof-recursive-pid".into(), + method: method.into(), assessed: true, evidence_complete: false, active: false, @@ -678,7 +680,7 @@ fn active_use_evidence( }; let Ok(raw_pid) = std::str::from_utf8(raw_pid) else { return GitWorktreeActiveUseEvidence { - method: "lsof-recursive-pid".into(), + method: method.into(), assessed: true, evidence_complete: false, active: false, @@ -689,7 +691,7 @@ fn active_use_evidence( }; let Ok(pid) = raw_pid.parse::() else { return GitWorktreeActiveUseEvidence { - method: "lsof-recursive-pid".into(), + method: method.into(), assessed: true, evidence_complete: false, active: false, @@ -706,7 +708,7 @@ fn active_use_evidence( let results_truncated = pids.len() > max_pids; let observed_pids: Vec<_> = pids.into_iter().take(max_pids).collect(); GitWorktreeActiveUseEvidence { - method: "lsof-recursive-pid".into(), + method: method.into(), assessed: true, evidence_complete: !results_truncated, active: !observed_pids.is_empty(), @@ -717,13 +719,19 @@ fn active_use_evidence( } #[cfg(not(unix))] -fn active_use_evidence( +pub(crate) fn active_use_evidence( _path: &Path, _timeout_ms: u64, _max_pids: usize, + recursive: bool, ) -> GitWorktreeActiveUseEvidence { GitWorktreeActiveUseEvidence { - method: "platform-active-use-probe-unavailable".into(), + method: if recursive { + "lsof-recursive-pid" + } else { + "lsof-file-pid" + } + .into(), assessed: true, evidence_complete: false, active: false, @@ -1171,6 +1179,7 @@ pub fn audit_git_worktrees( canonical_path, options.command_timeout_ms, options.max_active_pids, + true, ) } else { skipped_active_use("active-use-not-needed-for-preserved-worktree") diff --git a/src-tauri/src/icloud_sync_health.rs b/src-tauri/src/icloud_sync_health.rs index d9430278a..094c02127 100644 --- a/src-tauri/src/icloud_sync_health.rs +++ b/src-tauri/src/icloud_sync_health.rs @@ -13,6 +13,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +#[cfg(not(target_os = "macos"))] +use std::io::{Read, Write}; #[cfg(unix)] use std::os::unix::fs::MetadataExt; @@ -22,6 +24,10 @@ const CP_PATH: &str = "/bin/cp"; const PROBE_TIMEOUT: Duration = Duration::from_secs(5); const SNAPSHOT_COPY_TIMEOUT: Duration = Duration::from_secs(5); const SNAPSHOT_ATTEMPTS: usize = 3; +// CloudDocs' managed SQLite database can grow to many GiB. Never clone a database larger than +// this bounded amount during a read-only health probe: the immutable fallback below is slower and +// less complete, but it cannot unexpectedly consume the user's remaining disk while planning. +const MAX_SNAPSHOT_SOURCE_BYTES: u64 = 512 * 1024 * 1024; const MAX_STDOUT_BYTES: usize = 16 * 1024; const MAX_STDERR_BYTES: usize = 4 * 1024; const ITEM_ERROR_AGE_NOTICE_MS: u64 = 86_400_000; @@ -370,6 +376,7 @@ fn create_temporary_snapshot_directory() -> Result Result<(), String> { + ensure_snapshot_file_within_limit(source)?; let cp_metadata = fs::symlink_metadata(CP_PATH) .map_err(|_| "icloud-sync-health-clone-command-unavailable".to_string())?; if cp_metadata.file_type().is_symlink() || !cp_metadata.is_file() { @@ -384,15 +391,77 @@ fn clone_snapshot_file(source: &Path, destination: &Path) -> Result<(), String> .stderr(Stdio::null()) .spawn() .map_err(|_| "icloud-sync-health-clone-command-spawn-failed".to_string())?; - run_bounded_child(child, SNAPSHOT_COPY_TIMEOUT) - .map_err(|_| "icloud-sync-health-clone-command-failed".to_string()) + if run_bounded_child(child, SNAPSHOT_COPY_TIMEOUT).is_err() { + let _ = fs::remove_file(destination); + return Err("icloud-sync-health-clone-command-failed".into()); + } + ensure_snapshot_file_with_cleanup(destination) } #[cfg(not(target_os = "macos"))] fn clone_snapshot_file(source: &Path, destination: &Path) -> Result<(), String> { - fs::copy(source, destination) - .map(|_| ()) - .map_err(|_| "icloud-sync-health-snapshot-copy-failed".into()) + ensure_snapshot_file_within_limit(source)?; + let mut input = fs::File::open(source) + .map_err(|_| "icloud-sync-health-snapshot-copy-failed".to_string())?; + let mut output = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(destination) + .map_err(|_| "icloud-sync-health-snapshot-copy-failed".to_string())?; + let mut copied = 0u64; + let mut buffer = [0u8; 64 * 1024]; + loop { + let remaining = MAX_SNAPSHOT_SOURCE_BYTES.saturating_sub(copied); + let read_limit = remaining.saturating_add(1).min(buffer.len() as u64) as usize; + let read = input + .read(&mut buffer[..read_limit]) + .map_err(|_| "icloud-sync-health-snapshot-copy-failed".to_string()); + let read = match read { + Ok(read) => read, + Err(error) => { + let _ = fs::remove_file(destination); + return Err(error); + } + }; + if read == 0 { + if output.sync_all().is_err() { + let _ = fs::remove_file(destination); + return Err("icloud-sync-health-snapshot-copy-failed".into()); + } + break; + } + if copied.saturating_add(read as u64) > MAX_SNAPSHOT_SOURCE_BYTES { + let _ = fs::remove_file(destination); + return Err("icloud-sync-health-snapshot-source-too-large".into()); + } + if output.write_all(&buffer[..read]).is_err() { + let _ = fs::remove_file(destination); + return Err("icloud-sync-health-snapshot-copy-failed".into()); + } + copied = copied.saturating_add(read as u64); + } + ensure_snapshot_file_with_cleanup(destination) +} + +fn ensure_snapshot_file_within_limit(path: &Path) -> Result<(), String> { + let identity = source_file_identity(path, true)?; + if identity + .as_ref() + .is_some_and(|identity| identity.logical_bytes > MAX_SNAPSHOT_SOURCE_BYTES) + { + return Err("icloud-sync-health-snapshot-source-too-large".into()); + } + Ok(()) +} + +fn ensure_snapshot_file_with_cleanup(path: &Path) -> Result<(), String> { + match ensure_snapshot_file_within_limit(path) { + Ok(()) => Ok(()), + Err(error) => { + let _ = fs::remove_file(path); + Err(error) + } + } } struct ClientDatabaseSnapshot { @@ -406,7 +475,19 @@ fn clone_client_database_snapshot(db_dir: &Path) -> Result MAX_SNAPSHOT_SOURCE_BYTES) + { + return Err("icloud-sync-health-snapshot-source-too-large".into()); + } let before_wal = source_file_identity(&source_wal, false)?; + if before_wal + .as_ref() + .is_some_and(|identity| identity.logical_bytes > MAX_SNAPSHOT_SOURCE_BYTES) + { + return Err("icloud-sync-health-snapshot-source-too-large".into()); + } let directory = create_temporary_snapshot_directory()?; let client_db = directory.path.join("client.db"); clone_snapshot_file(&source_db, &client_db)?; @@ -882,6 +963,35 @@ mod tests { ); } + #[test] + fn oversized_cloud_docs_database_fails_closed_before_snapshot_copy() { + let source = tempfile::tempdir().unwrap(); + let client_db = source.path().join("client.db"); + fs::File::create(&client_db) + .unwrap() + .set_len(MAX_SNAPSHOT_SOURCE_BYTES + 1) + .unwrap(); + + let error = match clone_client_database_snapshot(source.path()) { + Ok(_) => panic!("oversized database must not be snapshotted"), + Err(error) => error, + }; + assert_eq!(error, "icloud-sync-health-snapshot-source-too-large"); + + fs::write(&client_db, b"within-limit").unwrap(); + let client_db_wal = source.path().join("client.db-wal"); + fs::File::create(&client_db_wal) + .unwrap() + .set_len(MAX_SNAPSHOT_SOURCE_BYTES + 1) + .unwrap(); + + let error = match clone_client_database_snapshot(source.path()) { + Ok(_) => panic!("oversized WAL must not be snapshotted"), + Err(error) => error, + }; + assert_eq!(error, "icloud-sync-health-snapshot-source-too-large"); + } + #[cfg(target_os = "macos")] #[test] fn copy_on_write_snapshot_clones_main_and_wal_then_removes_temporary_files() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6dd64cb87..e5a1be0fc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -94,6 +94,7 @@ pub fn run() { commands::list_dev_artifacts, commands::clean_paths, cache_cleanup::clean_cache_contents, + commands::clean_dev_artifacts, commands::recent_operations, commands::expand_clean_targets, commands::find_duplicate_files, diff --git a/src-tauri/src/reclaim.rs b/src-tauri/src/reclaim.rs index 1217a3b5d..a97cb3476 100644 --- a/src-tauri/src/reclaim.rs +++ b/src-tauri/src/reclaim.rs @@ -33,6 +33,10 @@ pub const REASON_ALLOCATED_UNAVAILABLE: &str = "allocated-size-unavailable"; pub const REASON_EVIDENCE_INCOMPLETE: &str = "evidence-incomplete-skipped-entries"; /// Indicates that moving an item to Trash does not immediately return its blocks. pub const REASON_TRASH_RETAINS: &str = "trash-retains-bytes-until-emptied"; +/// Bounded timeout used by the optional active-use probe. +pub const ACTIVE_USE_PROBE_TIMEOUT_MS: u64 = 2_000; +/// Maximum process identifiers retained by the optional active-use probe. +pub const ACTIVE_USE_PROBE_MAX_PIDS: usize = 128; /// Destructive lifecycle whose consequences the read-only plan is estimating. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -118,6 +122,16 @@ pub struct PathReclaimEstimate { pub skipped: u64, /// Logical, allocation, and physical-reclaimability evidence for this root. pub estimate: ReclaimEstimate, + /// Optional bounded `lsof` evidence. Omitted unless the caller explicitly requests it. + #[serde(skip_serializing_if = "Option::is_none")] + pub active_use: Option, +} + +/// Optional evidence controls for a reclaim plan. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ReclaimPlanOptions { + /// Collect bounded process/file-use evidence for each normalized root. + pub include_active_use: bool, } /// Read-only reclaim evidence for a normalized, deduplicated path selection. @@ -353,6 +367,7 @@ fn scan_root( root: &Path, operation: PlannedOperation, totals: &mut Accumulator, + options: ReclaimPlanOptions, ) -> Result { let metadata = std::fs::metadata(root) .map_err(|error| format!("cannot inspect {}: {error}", root.display()))?; @@ -420,6 +435,15 @@ fn scan_root( RootKind::Directory }; + let active_use = options.include_active_use.then(|| { + crate::git_worktree::active_use_evidence( + root, + ACTIVE_USE_PROBE_TIMEOUT_MS, + ACTIVE_USE_PROBE_MAX_PIDS, + matches!(kind, RootKind::Directory), + ) + }); + Ok(PathReclaimEstimate { path: validated_evidence_path(root)?, kind, @@ -427,6 +451,7 @@ fn scan_root( dirs: local.dirs, skipped: local.skipped, estimate: estimate(&local, operation), + active_use, }) } @@ -434,12 +459,21 @@ fn scan_root( pub fn plan_reclaim( raw_paths: &[PathBuf], operation: PlannedOperation, +) -> Result { + plan_reclaim_with_options(raw_paths, operation, ReclaimPlanOptions::default()) +} + +/// Builds a read-only plan with explicit evidence controls. +pub fn plan_reclaim_with_options( + raw_paths: &[PathBuf], + operation: PlannedOperation, + options: ReclaimPlanOptions, ) -> Result { let roots = normalize_roots(raw_paths)?; let mut totals = Accumulator::new(); let mut paths = Vec::with_capacity(roots.len()); for root in roots { - paths.push(scan_root(&root, operation, &mut totals)?); + paths.push(scan_root(&root, operation, &mut totals, options)?); } Ok(ReclaimPlan { @@ -497,6 +531,27 @@ mod tests { let json = serde_json::to_value(&plan).unwrap(); assert_eq!(json["schema_kind"], "disksage.reclaim-plan"); assert_eq!(json["schema_version"], 1); + assert!(json["paths"][0].get("active_use").is_none()); + } + + #[test] + fn active_use_evidence_is_opt_in_and_path_local() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("payload.bin"); + fs::write(&file, b"payload").unwrap(); + + let plan = plan_reclaim_with_options( + &[file], + PlannedOperation::Trash, + ReclaimPlanOptions { + include_active_use: true, + }, + ) + .unwrap(); + let evidence = plan.paths[0].active_use.as_ref().unwrap(); + assert!(evidence.evidence_complete || evidence.error.is_some()); + assert_eq!(evidence.method, "lsof-file-pid"); + assert!(evidence.observed_pids.len() <= ACTIVE_USE_PROBE_MAX_PIDS); } #[test] diff --git a/src-tauri/src/safety.rs b/src-tauri/src/safety.rs index 1a581b504..d533e203f 100644 --- a/src-tauri/src/safety.rs +++ b/src-tauri/src/safety.rs @@ -1,4 +1,5 @@ use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; #[derive(Debug)] pub enum SafetyError { @@ -116,6 +117,61 @@ pub fn is_protected(path: &Path) -> bool { false } +/// Stable identity for one filesystem object. Metadata fingerprints describe a tree, while this +/// identity binds the later trash operation to the exact directory entry observed at review time. +/// Unix uses the device/inode pair. Windows obtains the equivalent volume/file-index identity from +/// an open handle in [`filesystem_object_id`], because the stable `std` metadata accessors are not +/// available on the supported Rust toolchains. Unsupported platforms fail closed because a +/// path-only fallback would reintroduce a replacement race. +pub fn object_id_from_metadata(metadata: &std::fs::Metadata) -> Option { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + return Some(format!("unix:{}:{}", metadata.dev(), metadata.ino())); + } + #[cfg(windows)] + { + // Windows' `MetadataExt::{volume_serial_number,file_index}` methods are still gated + // behind the unstable `windows_by_handle` feature. Callers that need a Windows identity + // must use `filesystem_object_id`, which keeps the file handle open while deriving the + // same volume/file-index key through the `winapi-util` crate. + let _ = metadata; + return None; + } + #[cfg(not(any(unix, windows)))] + { + let _ = metadata; + None + } +} + +pub fn filesystem_object_id(path: &Path) -> std::io::Result { + #[cfg(windows)] + { + // `winapi-util` keeps the Windows handle open while querying the stable + // volume/file-index pair. This avoids the unstable `std` metadata accessors and avoids + // reducing the identity to a lossy hash. + let handle = winapi_util::Handle::from_path_any(path)?; + let info = winapi_util::file::information(&handle)?; + return Ok(format!( + "windows:{}:{}", + info.volume_serial_number(), + info.file_index() + )); + } + + #[cfg(not(windows))] + { + let metadata = std::fs::symlink_metadata(path)?; + object_id_from_metadata(&metadata).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::Unsupported, + "filesystem object identity is unavailable on this platform", + ) + }) + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct JournalEntry { pub ts_ms: u64, @@ -273,6 +329,167 @@ pub fn trash_delete( } } +static STAGING_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn create_private_staging_dir(path: &Path, now_ms: u64) -> std::io::Result { + let parent = path + .parent() + .unwrap_or_else(|| Path::new(".")); + let parent = std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf()); + let pid = std::process::id(); + for _ in 0..32 { + let serial = STAGING_COUNTER.fetch_add(1, Ordering::Relaxed); + let candidate = parent.join(format!( + ".disksage-trash-{}-{}-{}", + pid, now_ms, serial + )); + match std::fs::create_dir(&candidate) { + Ok(()) => { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + &candidate, + std::fs::Permissions::from_mode(0o700), + )?; + } + return Ok(candidate); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "could not allocate a private trash staging directory", + )) +} + +fn restore_staged_if_source_absent( + path: &Path, + staged: &Path, + staging_dir: &Path, +) -> Result<(), String> { + let source_absent = matches!( + std::fs::symlink_metadata(path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound + ); + if !source_absent { + return Err(format!( + "staged object retained at {}; source path reappeared", + staged.display() + )); + } + std::fs::rename(staged, path).map_err(|error| { + format!( + "staged restore failed for {}: {error}", + staged.display() + ) + })?; + std::fs::remove_dir(staging_dir).map_err(|error| { + format!( + "staging directory cleanup failed for {}: {error}", + staging_dir.display() + ) + })?; + Ok(()) +} + +/// Move the exact reviewed filesystem object into a private sibling staging directory before +/// handing it to the OS trash. The initial identity check prevents a stale path from being used; +/// the atomic rename plus a second identity check prevents a replacement that wins the race from +/// being trashed. If either check fails, the object is restored when the original path is free; +/// it is never silently deleted under a different identity. +pub fn trash_delete_if_identity( + path: &Path, + expected_object_id: &str, + bytes: u64, + journal_path: &Path, + now_ms: u64, +) -> Result<(), SafetyError> { + if path.components().any(|c| matches!(c, std::path::Component::ParentDir)) { + return Err(SafetyError::Protected(path.to_path_buf())); + } + let guard_path = strip_verbatim( + &std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()), + ); + if is_protected(&guard_path) { + return Err(SafetyError::Protected(path.to_path_buf())); + } + let actual = filesystem_object_id(path) + .map_err(|error| SafetyError::Trash(format!("object identity unavailable: {error}")))?; + if actual != expected_object_id { + return Err(SafetyError::Trash( + "개발 아티팩트의 파일시스템 객체가 바뀌었습니다. 다시 스캔하세요".into(), + )); + } + let file_name = path.file_name().ok_or_else(|| { + SafetyError::Trash("개발 아티팩트의 파일명이 없습니다. 다시 스캔하세요".into()) + })?; + let staging_dir = create_private_staging_dir(path, now_ms) + .map_err(|error| SafetyError::Trash(error.to_string()))?; + let staged = staging_dir.join(file_name); + let mut entry = JournalEntry { + ts_ms: now_ms, + op: "trash_delete".into(), + path: path.to_string_lossy().into_owned(), + bytes, + outcome: "pending".into(), + }; + if let Err(error) = journal_append(journal_path, &entry) { + let _ = std::fs::remove_dir(&staging_dir); + return Err(error); + } + + let result = (|| -> Result<(), SafetyError> { + if let Err(error) = std::fs::rename(path, &staged) { + let _ = std::fs::remove_dir(&staging_dir); + return Err(SafetyError::Trash(format!( + "atomic staging move failed: {error}" + ))); + } + let moved_id = filesystem_object_id(&staged).map_err(|error| { + let restore = restore_staged_if_source_absent(path, &staged, &staging_dir); + match restore { + Ok(()) => SafetyError::Trash(format!("staged object identity unavailable: {error}")), + Err(restore_error) => SafetyError::Trash(format!( + "staged object identity unavailable: {error}; {restore_error}" + )), + } + })?; + if moved_id != expected_object_id { + return match restore_staged_if_source_absent(path, &staged, &staging_dir) { + Ok(()) => Err(SafetyError::Trash( + "atomic staging move changed the filesystem object; nothing was trashed".into(), + )), + Err(restore_error) => Err(SafetyError::Trash(format!( + "atomic staging move changed the filesystem object; {restore_error}" + ))), + }; + } + if let Err(error) = trash::delete(&staged) { + return match restore_staged_if_source_absent(path, &staged, &staging_dir) { + Ok(()) => Err(SafetyError::Trash(error.to_string())), + Err(restore_error) => Err(SafetyError::Trash(format!( + "{}; {restore_error}", + error + ))), + }; + } + // Keep the empty identity-staging directory after a successful OS-trash move. The trash + // provider records the staged pathname as the undo target; retaining its parent preserves + // that recovery path. A later recovery pass may remove empty staging directories only + // after the corresponding trash item is no longer undoable. + Ok(()) + })(); + entry.outcome = match &result { + Ok(()) => "ok".into(), + Err(error) => format!("error:{error}"), + }; + journal_append(journal_path, &entry)?; + result +} + /// 두 경로가 같은 볼륨인지 — rename 가능 판정(순수). 목적지는 아직 없을 수 있어 부모로 판정. pub fn same_volume(src: &Path, dst: &Path) -> bool { let dst_probe = dst.parent().unwrap_or(dst); @@ -635,6 +852,66 @@ mod tests { assert!(journal_recent(&jp, 10).is_empty(), "보호 거부는 저널 이전에 일어나야 함"); } + #[test] + fn filesystem_object_id_is_available_for_regular_fixture() { + let tmp = tempfile::tempdir().unwrap(); + let victim = tmp.path().join("identity-fixture"); + std::fs::create_dir(&victim).unwrap(); + let first = filesystem_object_id(&victim).unwrap(); + let second = filesystem_object_id(&victim).unwrap(); + assert!(!first.is_empty()); + assert_eq!(first, second); + } + + #[test] + fn trash_delete_if_identity_rejects_a_replaced_object() { + let tmp = tempfile::tempdir().unwrap(); + let jp = tmp.path().join("j.jsonl"); + let victim = tmp.path().join("identity-target"); + let original = tmp.path().join("identity-original"); + let replacement = tmp.path().join("identity-replacement"); + std::fs::create_dir(&victim).unwrap(); + let expected = filesystem_object_id(&victim).unwrap(); + std::fs::rename(&victim, &original).unwrap(); + std::fs::create_dir(&replacement).unwrap(); + std::fs::rename(&replacement, &victim).unwrap(); + + let err = trash_delete_if_identity(&victim, &expected, 0, &jp, 1); + assert!(err.is_err()); + assert!(victim.exists(), "대체 객체는 삭제되지 않아야 함"); + assert!(original.exists(), "검토된 원래 객체도 보존되어야 함"); + assert!(journal_recent(&jp, 10).is_empty(), "stale identity는 저널/휴지통 전에 거부"); + } + + #[test] + fn staged_restore_reports_reappeared_source_and_retains_staged_object() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("source"); + let staging_dir = tmp.path().join(".disksage-trash-staging"); + let staged = staging_dir.join("source"); + std::fs::write(&source, b"replacement").unwrap(); + std::fs::create_dir(&staging_dir).unwrap(); + std::fs::write(&staged, b"reviewed").unwrap(); + + let error = restore_staged_if_source_absent(&source, &staged, &staging_dir).unwrap_err(); + assert!(error.contains(staged.to_string_lossy().as_ref())); + assert!(source.exists()); + assert!(staged.exists()); + } + + #[test] + fn staged_restore_reports_rename_failure_with_staged_path() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("source"); + let staging_dir = tmp.path().join(".disksage-trash-staging"); + let staged = staging_dir.join("source"); + std::fs::create_dir(&staging_dir).unwrap(); + + let error = restore_staged_if_source_absent(&source, &staged, &staging_dir).unwrap_err(); + assert!(error.contains(staged.to_string_lossy().as_ref())); + assert!(staging_dir.exists()); + } + #[test] fn trash_delete_missing_path_journals_error_outcome() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/lib/Cleanup.svelte b/src/lib/Cleanup.svelte index fcd59b632..bb3936138 100644 --- a/src/lib/Cleanup.svelte +++ b/src/lib/Cleanup.svelte @@ -43,19 +43,28 @@ } let totalSelected = $derived( - artifacts.filter((a) => selected.has(a.path)).reduce((sum, artifact) => sum + artifact.bytes, 0), + artifacts + .filter((a) => selected.has(a.path) && a.scan_complete && a.skipped === 0) + .reduce((sum, artifact) => sum + artifact.bytes, 0), ); - let selectionCount = $derived(artifacts.filter((a) => selected.has(a.path)).length); + let selectionCount = $derived( + artifacts.filter((a) => selected.has(a.path) && a.scan_complete && a.skipped === 0).length, + ); async function executeClean() { // 검토·확인 (스펙 §7-6): 명시적 승인 없이는 아무것도 실행되지 않는다 - const artifactPaths = artifacts.filter((a) => selected.has(a.path)).map((a) => a.path); - if (artifactPaths.length === 0) return; + const selectedArtifacts = artifacts.filter( + (a) => selected.has(a.path) && a.scan_complete && a.skipped === 0, + ); + if (selectedArtifacts.length === 0 || !scannedRoot) return; + const summary = selectedArtifacts.map( + (a) => `${a.path} (${fmtBytes(a.bytes)}, ${a.files}개) — 메타데이터 지문 ${a.fingerprint.slice(0, 12)}`, + ); const okay = await confirm( - `다음 ${artifactPaths.length}개 항목을 휴지통으로 보냅니다 (논리 크기 합계 ${fmtBytes(totalSelected)}):\n\n` + - artifactPaths.slice(0, 15).join("\n") + - (artifactPaths.length > 15 ? `\n… 외 ${artifactPaths.length - 15}개` : "") + + `다음 ${summary.length}개 항목을 휴지통으로 보냅니다 (논리 크기 합계 ${fmtBytes(totalSelected)}):\n\n` + + summary.slice(0, 15).join("\n") + + (summary.length > 15 ? `\n… 외 ${summary.length - 15}개` : "") + "\n\n휴지통에서 언제든 복원할 수 있습니다. 휴지통을 비우기 전에는 물리 공간이 회수되지 않으며, APFS 공유 블록 때문에 실제 회수량은 논리 크기보다 작을 수 있습니다.", { title: "DiskSage", kind: "warning" }, ); @@ -63,7 +72,7 @@ busy = true; try { - results = await api.cleanPaths(artifactPaths); + results = await api.cleanDevArtifacts(scannedRoot, 30, selectedArtifacts); selected = new Set(); await load(); } catch (e) { @@ -101,15 +110,21 @@
    {#each artifacts as a (a.path)}
  • -