From 943bf637ff179efe9d8863cc22a8f5725d068adb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 18:55:17 +0900 Subject: [PATCH 01/10] feat: revalidate developer artifact cleanup --- README.md | 2 +- src-tauri/src/commands.rs | 89 +++++++++++++++++++++++ src-tauri/src/dev_artifacts.rs | 127 +++++++++++++++++++++++++++++++-- src-tauri/src/lib.rs | 1 + src/lib/Cleanup.svelte | 34 ++++++--- src/lib/api.ts | 6 ++ 6 files changed, 243 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 755b922bc..3b8adc445 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 with byte/file counts and scan status; the Rust command re-scans immediately before trashing 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 undoable. 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/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3bbf97923..28d166a73 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -137,6 +137,45 @@ 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() + .flat_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 + && candidate.age_days == request.age_days + }); + if matches.is_none() { + return vec![CleanResult { + path: request.path.clone(), + ok: false, + error: "개발 아티팩트가 변경되었거나 메타데이터 스캔이 불완전합니다. 정리 전에 다시 스캔하세요".into(), + }]; + } + + clean_paths_inner(&[PathBuf::from(&request.path)], journal_path, now_ms) + }) + .collect() +} + /// 저널의 move 경로 필드 "src -> dst"를 분리 (순수 함수 — 테스트 대상). 구분자 없으면 None. pub fn parse_move_entry(path_field: &str) -> Option<(String, String)> { path_field @@ -424,6 +463,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 +2220,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..a3efd3e0b 100644 --- a/src-tauri/src/dev_artifacts.rs +++ b/src-tauri/src/dev_artifacts.rs @@ -1,14 +1,25 @@ 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, pub age_days: u64, } @@ -33,6 +44,108 @@ 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, +} + +/// 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 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 modified = entry + .metadata() + .ok() + .and_then(|m| modified_stamp(&m)) + .unwrap_or_else(|| { + manifest.skipped = manifest.skipped.saturating_add(1); + manifest.scan_complete = false; + "".into() + }); + manifest.records.push(format!("D\0{relative}\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 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{}\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 +202,7 @@ pub fn find_artifacts(root: &Path, min_age_days: u64, now_ms: u64) -> Vec Vec 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) && a.scan_complete && a.skipped === 0) + .reduce((s, a) => s + a.bytes, 0), ); let selectionCount = $derived( caches.filter((c) => selectedRules.has(c.id) && c.exists).length + - artifacts.filter((a) => selected.has(a.path)).length, + artifacts.filter((a) => selected.has(a.path) && a.scan_complete && a.skipped === 0).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 selectedArtifacts = artifacts.filter( + (a) => selected.has(a.path) && a.scan_complete && a.skipped === 0, + ); const summary = [ ...ruleDirs.map((c) => `${c.label} (${fmtBytes(c.bytes)}) — 내용물 비우기`), - ...artifactPaths, + ...selectedArtifacts.map( + (a) => `${a.path} (${fmtBytes(a.bytes)}, ${a.files}개) — 메타데이터 지문 ${a.fingerprint.slice(0, 12)}`, + ), ]; if (summary.length === 0) return; const okay = await confirm( @@ -73,11 +79,15 @@ busy = true; try { - const paths: string[] = [...artifactPaths]; + const paths: string[] = []; for (const c of ruleDirs) { paths.push(...(await api.expandCleanTargets(c.path))); } - results = await api.cleanPaths(paths); + const cacheResults = paths.length ? await api.cleanPaths(paths) : []; + const artifactResults = selectedArtifacts.length && scannedRoot + ? await api.cleanDevArtifacts(scannedRoot, 30, selectedArtifacts) + : []; + results = [...cacheResults, ...artifactResults]; selected = new Set(); selectedRules = new Set(); await load(); @@ -118,15 +128,21 @@
    {#each artifacts as a (a.path)}
  • -