From 50a897e75dbc0cec4e02b284da2e5cda03ab5511 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 11:44:22 +0900 Subject: [PATCH 001/138] feat: expose physical cache reclaim --- CHANGELOG.md | 5 ++ README.md | 2 + ...ache-cleanup-is-per-item-evidence-bound.md | 7 +++ src-tauri/src/cache_cleanup.rs | 12 +++++ src-tauri/src/commands.rs | 40 ++++++++++++++++ src-tauri/src/lib.rs | 2 + src/lib/Cleanup.svelte | 47 +++++++++++++++++++ src/lib/api.ts | 26 ++++++++++ src/lib/cacheCleanupFlowContract.test.ts | 5 ++ 9 files changed, 146 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bf76f051..c514a35bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Changed +- Show regenerable cache entries that are already in the Trash and let the operator permanently + remove only those structurally proven entries, so moving a cache to Trash can be followed by + an explicit action that actually releases local capacity. User files and unrelated Trash items + remain outside this action, each removal is revalidated and journaled, and the UI reports the + observed before/after available-space change when the filesystem provides it. - Keep coverage builds compile-safe by applying the same `not(coverage)` boundary to native-copy identity cleanup and dependent eviction helpers; the focused authority contract remains green. - Add durable private failure records in a separate journal directory and a receipt-bound diff --git a/README.md b/README.md index 2505726b3..5ce1e80a8 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ - ๐Ÿ—บ **Large file explorer** โ€” parallel scan with treemap visualization - ๐Ÿงน **Known cache & temp cleanup** โ€” OS, browser, and package-manager caches +- ๐Ÿ—‘๏ธ **Cache Trash recovery** โ€” review regenerable cache items already in Trash, then permanently + remove only those items to release local space; user files and other Trash entries stay untouched - ๐Ÿ›  **Dev artifact cleanup** โ€” stale `node_modules`, `target/`, `venv`, โ€ฆ - ๐Ÿ‘ฏ **Duplicate finder** โ€” size โ†’ partial hash โ†’ BLAKE3 full hash - ๐Ÿ—‚ **Ontology-based organizing** โ€” files classified into an OWL taxonomy you can edit; move plans use a complete bounded scan, bind metadata-first production-time lineage and source size/mtime, revalidate them immediately before moving, and skip File Provider dataless sources diff --git a/docs/architecture/adr/0002-cache-cleanup-is-per-item-evidence-bound.md b/docs/architecture/adr/0002-cache-cleanup-is-per-item-evidence-bound.md index e756baaff..75e1f4a35 100644 --- a/docs/architecture/adr/0002-cache-cleanup-is-per-item-evidence-bound.md +++ b/docs/architecture/adr/0002-cache-cleanup-is-per-item-evidence-bound.md @@ -96,6 +96,13 @@ rejects symlinks, rechecks the signature immediately before removal, and writes for both the pending and terminal outcome. This path never empties the Trash generally and never applies to user files or cloud-provider placeholders. +The desktop cleanup screen exposes the same two-step lifecycle: it lists the proven cache entries +already in Trash, then requires a native confirmation before calling the narrow purge command. +The screen reports the count and observed bytes rather than exposing structural signatures, captures +filesystem available-space observations around the purge when the native probe succeeds, and +refreshes the read-only list after execution so the operator can verify what remains. A missing +before/after observation never becomes a claim that physical capacity was recovered. + ## References - [ADR-0001: Provider evidence drives the cloud-offload Goal](0001-cloud-offload-goal-state.md) diff --git a/src-tauri/src/cache_cleanup.rs b/src-tauri/src/cache_cleanup.rs index 673ae3d7b..882fdf77f 100644 --- a/src-tauri/src/cache_cleanup.rs +++ b/src-tauri/src/cache_cleanup.rs @@ -54,6 +54,18 @@ pub struct CacheTrashPurgeResult { pub error: String, } +/// Permanent cache-Trash cleanup result with optional filesystem evidence captured around it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CacheTrashPurgeExecution { + pub schema_kind: String, + pub schema_version: u32, + pub items: Vec, + pub before_available_bytes: Option, + pub after_available_bytes: Option, + pub observed_available_gain_bytes: Option, +} + fn direct_child_is_dir(path: &Path, name: &str) -> bool { let child = path.join(name); std::fs::symlink_metadata(child) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4265d7751..c6c40ddbd 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -710,6 +710,46 @@ pub fn clean_regenerable_caches(app: AppHandle) -> Result, Stri )) } +/// Return only structurally proven, regenerable cache directories already in the user's Trash. +/// This is read-only evidence; arbitrary Trash entries are never included. +#[cfg(not(coverage))] +#[tauri::command] +pub fn list_proven_cache_trash() -> Result, String> { + let bases = rules::BaseDirs::from_env().ok_or("cache-base-directories-unavailable")?; + Ok(crate::cache_cleanup::proven_cache_trash_candidates(&bases.home)) +} + +/// Permanently remove only the reviewed, structurally proven regenerable cache entries in Trash. +/// The journal records each revalidated item and the operation is never a general Trash purge. +#[cfg(not(coverage))] +#[tauri::command] +pub fn purge_proven_cache_trash( + app: AppHandle, +) -> Result { + let bases = rules::BaseDirs::from_env().ok_or("cache-base-directories-unavailable")?; + let journal_path = journal_file_path(&app)?; + let before = crate::volume_pressure::snapshot_volume(&bases.home, now_ms()).ok(); + let items = crate::cache_cleanup::purge_proven_cache_trash( + &bases.home, + &journal_path, + now_ms(), + )?; + let after = crate::volume_pressure::snapshot_volume(&bases.home, now_ms()).ok(); + let before_available_bytes = before.as_ref().map(|snapshot| snapshot.available_bytes); + let after_available_bytes = after.as_ref().map(|snapshot| snapshot.available_bytes); + let observed_available_gain_bytes = before_available_bytes + .zip(after_available_bytes) + .and_then(|(before, after)| after.checked_sub(before)); + Ok(crate::cache_cleanup::CacheTrashPurgeExecution { + schema_kind: "disksage.cache-trash-purge".into(), + schema_version: 1, + items, + before_available_bytes, + after_available_bytes, + observed_available_gain_bytes, + }) +} + #[cfg(not(coverage))] #[tauri::command] pub fn list_dev_artifacts( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fcaa9a098..2d83272f7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -111,6 +111,8 @@ pub fn run() { commands::top_files, commands::list_cache_candidates, commands::clean_regenerable_caches, + commands::list_proven_cache_trash, + commands::purge_proven_cache_trash, cache_cleanup::list_cache_targets, commands::list_dev_artifacts, commands::clean_paths, diff --git a/src/lib/Cleanup.svelte b/src/lib/Cleanup.svelte index 2c18753cc..1ae6900a9 100644 --- a/src/lib/Cleanup.svelte +++ b/src/lib/Cleanup.svelte @@ -10,6 +10,8 @@ let { scannedRoot }: { scannedRoot: string | null } = $props(); let caches: api.CacheCandidate[] = $state([]); + let cacheTrash: api.CacheTrashCandidate[] = $state([]); + let cacheTrashExecution: api.CacheTrashPurgeExecution | null = $state(null); let artifacts: api.DevArtifact[] = $state([]); let selected: Set = $state(new Set()); let results: api.CleanResult[] = $state([]); @@ -40,6 +42,7 @@ loadError = ""; try { caches = await api.listCacheCandidates(); + cacheTrash = await api.listProvenCacheTrash(); artifacts = scannedRoot ? await api.listDevArtifacts(scannedRoot) : []; loadVerdicts(artifacts.map((a) => a.path)); } catch (e) { @@ -141,6 +144,28 @@ } } + async function purgeProvenCacheTrash() { + if (busy || cacheTrash.length === 0) return; + const bytes = cacheTrash.reduce((sum, candidate) => sum + candidate.bytes, 0); + const okay = await confirm( + `ํœด์ง€ํ†ต์— ๋‚จ์•„ ์žˆ๋Š” ์žฌ์ƒ์„ฑ ๊ฐ€๋Šฅํ•œ ์บ์‹œ ${cacheTrash.length}๊ฐœ(${fmtBytes(bytes)})๋ฅผ ์˜๊ตฌ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.\n\n` + + "์ด ํ•ญ๋ชฉ์€ ๋ณต์›ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž ํŒŒ์ผ๊ณผ ๋‹ค๋ฅธ ํœด์ง€ํ†ต ํ•ญ๋ชฉ์€ ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", + { title: "DiskSage ํœด์ง€ํ†ต ์ •๋ฆฌ", kind: "warning" }, + ); + if (!okay) return; + busy = true; + loadError = ""; + cacheTrashExecution = null; + try { + cacheTrashExecution = await api.purgeProvenCacheTrash(); + await load(); + } catch (e) { + loadError = String(e); + } finally { + busy = false; + } + } + function toggle(set: Set, key: string) { const next = new Set(set); next.has(key) ? next.delete(key) : next.add(key); @@ -204,6 +229,27 @@

npmยทpnpmยทAdobeยทEdgeยทuvยทTrivy ์บ์‹œ๋งŒ ๋Œ€์ƒ์œผ๋กœ ํ•˜๋ฉฐ, ์‚ฌ์šฉ ์ค‘์ด๊ฑฐ๋‚˜ ์ฆ๊ฑฐ๊ฐ€ ๋ฐ”๋€ ํ•ญ๋ชฉ์€ ์ž๋™์œผ๋กœ ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค.

+ {#if cacheTrash.length > 0} +
+

+ ํœด์ง€ํ†ต์— ๋‚จ์€ ์žฌ์ƒ์„ฑ ๊ฐ€๋Šฅํ•œ ์บ์‹œ {cacheTrash.length}๊ฐœ({fmtBytes(cacheTrash.reduce((sum, item) => sum + item.bytes, 0))})๊ฐ€ + ํ™•์ธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์˜๊ตฌ ์‚ญ์ œํ•˜๋ฉด ์‹ค์ œ ์ €์žฅ ๊ณต๊ฐ„์„ ํšŒ์ˆ˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +

+ +
+ {/if} + {#if cacheTrashExecution} + {@const purged = cacheTrashExecution.items.filter((item) => item.purged).length} +

+ ์žฌ์ƒ์„ฑ ์บ์‹œ {purged}/{cacheTrashExecution.items.length}๊ฐœ๋ฅผ ์˜๊ตฌ ์‚ญ์ œํ–ˆ์Šต๋‹ˆ๋‹ค. + {cacheTrashExecution.observed_available_gain_bytes === null + ? "์ „ํ›„ ์ €์žฅ ๊ณต๊ฐ„์€ ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค." + : `๊ฐ€์šฉ ๊ณต๊ฐ„ ์ฆ๊ฐ€ ${fmtBytes(cacheTrashExecution.observed_available_gain_bytes)}์ž…๋‹ˆ๋‹ค.`} + ์‹คํŒจํ•œ ํ•ญ๋ชฉ์€ ๋‹ค์‹œ ํ™•์ธํ•˜์‹ญ์‹œ์˜ค. +

+ {/if} {#if cacheRetryMessage}

{cacheRetryMessage}

{/if}
    {#each caches as c (c.id)} @@ -341,6 +387,7 @@ .path { color: #999; font-size: 0.8rem; overflow-wrap: anywhere; text-align: right; } .disabled { color: #aaa; } .notice { color: #555; font-size: 0.9rem; } + .trash-cleanup { display: grid; gap: 0.5rem; } .error, .errors { color: #b00; } .errors { font-size: 0.85rem; } .podman-evidence { margin-top: 0.75rem; padding: 0.75rem; border: 1px solid #b7c6d8; border-radius: 4px; background: #f8fafc; } diff --git a/src/lib/api.ts b/src/lib/api.ts index 7f0e79b13..efceb09d3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -38,6 +38,28 @@ export interface CacheTarget { modified_ms: number; object_id: string; } +export interface CacheTrashCandidate { + name: string; + path: string; + bytes: number; + signature: string; +} +export interface CacheTrashPurgeResult { + name: string; + path: string; + bytes: number; + signature: string; + purged: boolean; + error: string; +} +export interface CacheTrashPurgeExecution { + schema_kind: "disksage.cache-trash-purge"; + schema_version: number; + items: CacheTrashPurgeResult[]; + before_available_bytes: number | null; + after_available_bytes: number | null; + observed_available_gain_bytes: number | null; +} export interface DevArtifact { path: string; kind: string; @@ -136,6 +158,10 @@ export interface DupeGroup { export const listCacheCandidates = () => invoke("list_cache_candidates"); export const cleanRegenerableCaches = () => invoke("clean_regenerable_caches"); +export const listProvenCacheTrash = () => + invoke("list_proven_cache_trash"); +export const purgeProvenCacheTrash = () => + invoke("purge_proven_cache_trash"); export const listCacheTargets = (dir: string) => invoke("list_cache_targets", { dir }); export const cleanCacheContents = (dir: string, targets: CacheTarget[]) => diff --git a/src/lib/cacheCleanupFlowContract.test.ts b/src/lib/cacheCleanupFlowContract.test.ts index 842a5cba7..f1c1a8441 100644 --- a/src/lib/cacheCleanupFlowContract.test.ts +++ b/src/lib/cacheCleanupFlowContract.test.ts @@ -18,6 +18,9 @@ describe("cache cleanup execution boundary", () => { expect(cleanup).toContain("api.listCacheTargets(candidate.path)"); expect(cleanup).toContain("api.cleanCacheContents(candidate.path, targets)"); expect(cleanup).toContain("api.cleanRegenerableCaches()"); + expect(cleanup).toContain("api.listProvenCacheTrash()"); + expect(cleanup).toContain("api.purgeProvenCacheTrash()"); + expect(cleanup).toContain("observed_available_gain_bytes"); expect(cleanup).toContain("๊ฐ์ฒด ์ง€๋ฌธยทํฌ๊ธฐยท์ˆ˜์ •์‹œ๊ฐ"); expect(cleanup).toContain("npmยทpnpmยทAdobeยทEdgeยทuvยทTrivy ์บ์‹œ๋งŒ ๋Œ€์ƒ์œผ๋กœ"); expect(backend).toContain("pub fn clean_cache_contents("); @@ -26,6 +29,8 @@ describe("cache cleanup execution boundary", () => { expect(tauri).toContain("cache_cleanup::clean_cache_contents"); expect(tauri).toContain("cache_cleanup::list_cache_targets"); expect(tauri).toContain("commands::clean_regenerable_caches"); + expect(tauri).toContain("commands::list_proven_cache_trash"); + expect(tauri).toContain("commands::purge_proven_cache_trash"); }); it("surfaces an actionable status when a cache candidate has no direct cleanup targets", () => { From ad10a5ba0a30ff6ec143007925b86cfae441d890 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 11:50:05 +0900 Subject: [PATCH 002/138] docs: record physical reclaim loop evidence --- docs/product-technical-gap-baseline.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 5ecd46866..7f8f918d4 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -165,6 +165,26 @@ authoritative, and no merge is claimed from queued or stale status. At each scheduled or operator loop, update this file only with new dated evidence: current head, open-PR/check state, provider receipt state, disk headroom, and the smallest acceptance proof completed. Do not convert an incomplete provider probe, filename date, model answer, or GitHub review comment into a transfer or deletion authority. +## 2026-08-26 physical reclaim loop + +- PR #263 (`50a897e75dbc0cec4e02b284da2e5cda03ab5511`, base `2a727dab04cd6bd73c68d75c54c3e1563bcb16ca`) + closes the physical-reclaim gap after cache cleanup: the desktop cleanup screen lists only + regenerable cache entries already in OS Trash, requires a native confirmation, permanently + removes only those revalidated entries, and reports the observed before/after available-space + change when the filesystem probe succeeds. User files, cloud placeholders, and unrelated Trash + entries remain outside the action; each item remains journaled. +- The exact-head PR is open with auto-merge enabled, but hosted checks are still queued or running + and no qualifying independent approval is recorded. This is not merge evidence; protected merge + remains blocked until the current head has green required checks and a fresh approval. +- Local verification for the implementation passed 135 frontend tests, `svelte-check` with zero + diagnostics, 749 Rust library tests (one ignored), and `git diff --check`. Rebuildable temporary + `src-tauri/target` and `node_modules` artifacts were removed from the temporary checkout after + verification; APFS availability measured 36 GiB (96% capacity used) afterward. Active uv, + npm, OpenCode, Podman, File Provider, and user data were not removed. +- Filename dates remain secondary production evidence: embedded metadata is evaluated first, then + an unambiguous filename token such as `2026-04-28` or `251210`, then filesystem times. No cache + purge, cloud transfer, or source eviction treats a filename date as authority. + ## 2026-08-21 lineage graph update - Source head `677042467b3398866757f39b9475bd0b267abc75` now exports path-free ontology relations for From 080274fc0b99017bda47bd7d17d18ac2698691e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 11:57:50 +0900 Subject: [PATCH 003/138] fix: preserve cache purge results on journal errors --- src-tauri/src/cache_cleanup.rs | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/cache_cleanup.rs b/src-tauri/src/cache_cleanup.rs index 882fdf77f..e4b0465f5 100644 --- a/src-tauri/src/cache_cleanup.rs +++ b/src-tauri/src/cache_cleanup.rs @@ -219,19 +219,33 @@ pub fn purge_proven_cache_trash( Ok(()) => "ok".into(), Err(error) => format!("error:{error}"), }; - crate::safety::journal_append(journal_path, &entry).map_err(|error| error.to_string())?; + let journal_error = crate::safety::journal_append(journal_path, &entry) + .err() + .map(|error| error.to_string()); + let operation_error = outcome.as_ref().err().cloned(); results.push(CacheTrashPurgeResult { name: candidate.name, path: candidate.path, bytes: candidate.bytes, signature: candidate.signature, purged: outcome.is_ok(), - error: outcome.err().unwrap_or_default(), + error: merge_purge_errors(operation_error, journal_error), }); } Ok(results) } +fn merge_purge_errors(operation_error: Option, journal_error: Option) -> String { + match (operation_error, journal_error) { + (None, None) => String::new(), + (Some(operation), None) => operation, + (None, Some(journal)) => format!("purged-but-journal-write-failed:{journal}"), + (Some(operation), Some(journal)) => { + format!("{operation};journal-write-failed:{journal}") + } + } +} + fn active_use_blocker( evidence: &crate::git_worktree::GitWorktreeActiveUseEvidence, ) -> Option<&'static str> { @@ -484,6 +498,19 @@ mod tests { assert!(journal_text.contains("\"outcome\":\"ok\"")); } + #[test] + fn purge_error_keeps_terminal_journal_failure_visible() { + assert_eq!(merge_purge_errors(None, None), ""); + assert_eq!( + merge_purge_errors(None, Some("disk-full".into())), + "purged-but-journal-write-failed:disk-full" + ); + assert_eq!( + merge_purge_errors(Some("remove-failed".into()), Some("disk-full".into())), + "remove-failed;journal-write-failed:disk-full" + ); + } + #[cfg(unix)] #[test] fn cleanup_rejects_symlinked_catalog_root_without_touching_outside_data() { From baaa99f67e9d14894cd4268d0b06f65692bdb214 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 11:58:18 +0900 Subject: [PATCH 004/138] docs: describe journal failure outcomes --- CHANGELOG.md | 2 ++ .../adr/0002-cache-cleanup-is-per-item-evidence-bound.md | 3 +++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c514a35bb..16aeed87e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and an explicit action that actually releases local capacity. User files and unrelated Trash items remain outside this action, each removal is revalidated and journaled, and the UI reports the observed before/after available-space change when the filesystem provides it. +- Preserve each deletion result when a terminal journal write fails, marking the audit failure on + that item instead of hiding an already-completed removal behind an all-or-nothing error. - Keep coverage builds compile-safe by applying the same `not(coverage)` boundary to native-copy identity cleanup and dependent eviction helpers; the focused authority contract remains green. - Add durable private failure records in a separate journal directory and a receipt-bound diff --git a/docs/architecture/adr/0002-cache-cleanup-is-per-item-evidence-bound.md b/docs/architecture/adr/0002-cache-cleanup-is-per-item-evidence-bound.md index 75e1f4a35..19c2af456 100644 --- a/docs/architecture/adr/0002-cache-cleanup-is-per-item-evidence-bound.md +++ b/docs/architecture/adr/0002-cache-cleanup-is-per-item-evidence-bound.md @@ -102,6 +102,9 @@ The screen reports the count and observed bytes rather than exposing structural filesystem available-space observations around the purge when the native probe succeeds, and refreshes the read-only list after execution so the operator can verify what remains. A missing before/after observation never becomes a claim that physical capacity was recovered. +If a terminal journal write fails after an item was removed, the item result keeps `purged=true` +and carries the journal error so the caller cannot mistake a partial audit failure for an all-or- +nothing operation or silently lose the deletion outcome. ## References From 3c6576052145e576ccc6b75016e1b504d3ba466c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 12:02:16 +0900 Subject: [PATCH 005/138] docs: bind gap baseline to purge fix --- docs/product-technical-gap-baseline.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 7f8f918d4..16a4f3208 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -167,7 +167,8 @@ At each scheduled or operator loop, update this file only with new dated evidenc ## 2026-08-26 physical reclaim loop -- PR #263 (`50a897e75dbc0cec4e02b284da2e5cda03ab5511`, base `2a727dab04cd6bd73c68d75c54c3e1563bcb16ca`) +- PR #263 implementation head (`080274fc0b99017bda47bd7d17d18ac2698691e9`, base + `2a727dab04cd6bd73c68d75c54c3e1563bcb16ca`; documentation follow-up `baaa99f67e9d14894cd4268d0b06f65692bdb214`) closes the physical-reclaim gap after cache cleanup: the desktop cleanup screen lists only regenerable cache entries already in OS Trash, requires a native confirmation, permanently removes only those revalidated entries, and reports the observed before/after available-space @@ -176,6 +177,9 @@ At each scheduled or operator loop, update this file only with new dated evidenc - The exact-head PR is open with auto-merge enabled, but hosted checks are still queued or running and no qualifying independent approval is recorded. This is not merge evidence; protected merge remains blocked until the current head has green required checks and a fresh approval. +- A terminal journal-write failure after removal now remains attached to that item's result, so an + irreversible deletion is never hidden behind a generic all-or-nothing error; the audit gap stays + visible for follow-up instead of being mistaken for a successful complete journal. - Local verification for the implementation passed 135 frontend tests, `svelte-check` with zero diagnostics, 749 Rust library tests (one ignored), and `git diff --check`. Rebuildable temporary `src-tauri/target` and `node_modules` artifacts were removed from the temporary checkout after From 9a2a7fc7a3b5a46763f9eafcb0c658edc93bfa53 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 12:15:32 +0900 Subject: [PATCH 006/138] fix: bind cache purge to approved candidate set --- CHANGELOG.md | 2 + ...ache-cleanup-is-per-item-evidence-bound.md | 4 ++ docs/product-technical-gap-baseline.md | 3 ++ src-tauri/src/cache_cleanup.rs | 36 +++++++++++++++++- src-tauri/src/commands.rs | 16 ++++++++ src-tauri/src/lib.rs | 1 + src/lib/Cleanup.svelte | 38 +++++++++++++------ src/lib/api.ts | 6 ++- src/lib/cacheCleanupFlowContract.test.ts | 13 +++++-- .../cacheCleanupReadOnlyUiContract.test.ts | 3 +- 10 files changed, 104 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16aeed87e..ec6f5dc45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and observed before/after available-space change when the filesystem provides it. - Preserve each deletion result when a terminal journal write fails, marking the audit failure on that item instead of hiding an already-completed removal behind an all-or-nothing error. +- Bind the desktop purge action to the current proven-Trash candidate set so a changed list is + rejected before deletion; pending journal failures now remain visible per item as well. - Keep coverage builds compile-safe by applying the same `not(coverage)` boundary to native-copy identity cleanup and dependent eviction helpers; the focused authority contract remains green. - Add durable private failure records in a separate journal directory and a receipt-bound diff --git a/docs/architecture/adr/0002-cache-cleanup-is-per-item-evidence-bound.md b/docs/architecture/adr/0002-cache-cleanup-is-per-item-evidence-bound.md index 19c2af456..6d3ca371a 100644 --- a/docs/architecture/adr/0002-cache-cleanup-is-per-item-evidence-bound.md +++ b/docs/architecture/adr/0002-cache-cleanup-is-per-item-evidence-bound.md @@ -102,6 +102,10 @@ The screen reports the count and observed bytes rather than exposing structural filesystem available-space observations around the purge when the native probe succeeds, and refreshes the read-only list after execution so the operator can verify what remains. A missing before/after observation never becomes a claim that physical capacity was recovered. +The desktop command also requires a candidate-set-bound approval phrase generated from the current +proven list; a changed Trash set invalidates the phrase before any deletion. If a pending or terminal +journal write fails, the affected item is returned as an explicit failed result while earlier +deletions remain visible, so a partial operation is never reported as an unqualified success. If a terminal journal write fails after an item was removed, the item result keeps `purged=true` and carries the journal error so the caller cannot mistake a partial audit failure for an all-or- nothing operation or silently lose the deletion outcome. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 16a4f3208..5cbb05e45 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -180,6 +180,9 @@ At each scheduled or operator loop, update this file only with new dated evidenc - A terminal journal-write failure after removal now remains attached to that item's result, so an irreversible deletion is never hidden behind a generic all-or-nothing error; the audit gap stays visible for follow-up instead of being mistaken for a successful complete journal. +- The desktop purge command now requires an opaque phrase bound to the current proven-Trash set; + changed Trash contents invalidate the approval before deletion, and pending journal failures are + returned as item-level failures without discarding earlier results. - Local verification for the implementation passed 135 frontend tests, `svelte-check` with zero diagnostics, 749 Rust library tests (one ignored), and `git diff --check`. Rebuildable temporary `src-tauri/target` and `node_modules` artifacts were removed from the temporary checkout after diff --git a/src-tauri/src/cache_cleanup.rs b/src-tauri/src/cache_cleanup.rs index e4b0465f5..6bad33ef8 100644 --- a/src-tauri/src/cache_cleanup.rs +++ b/src-tauri/src/cache_cleanup.rs @@ -186,6 +186,24 @@ pub fn proven_cache_trash_candidates(home: &Path) -> Vec { candidates } +/// Return a candidate-set-bound approval phrase for the desktop confirmation boundary. +/// The phrase is opaque to the customer and changes whenever the proven Trash set changes. +pub fn proven_cache_trash_approval_phrase(home: &Path) -> String { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"disksage.cache-trash-purge-approval.v1\0"); + for candidate in proven_cache_trash_candidates(home) { + for field in [candidate.name, candidate.path, candidate.signature] { + hasher.update(&(field.len() as u64).to_le_bytes()); + hasher.update(field.as_bytes()); + } + hasher.update(&candidate.bytes.to_le_bytes()); + } + format!( + "DiskSage cache-trash purge approval {}", + hasher.finalize().to_hex() + ) +} + /// Permanently remove only the proven cache directories in OS Trash. The explicit CLI flag is the /// approval boundary; each object is rechecked immediately before removal and journaled. pub fn purge_proven_cache_trash( @@ -204,7 +222,17 @@ pub fn purge_proven_cache_trash( bytes: candidate.bytes, outcome: "pending".into(), }; - crate::safety::journal_append(journal_path, &entry).map_err(|error| error.to_string())?; + if let Err(error) = crate::safety::journal_append(journal_path, &entry) { + results.push(CacheTrashPurgeResult { + name: candidate.name, + path: candidate.path, + bytes: candidate.bytes, + signature: candidate.signature, + purged: false, + error: format!("journal-write-failed:{error}"), + }); + continue; + } let outcome = if looks_like_proven_cache_trash(&path, &candidate.name) .is_some_and(|signature| signature == candidate.signature) { @@ -487,6 +515,8 @@ mod tests { assert_eq!(candidates.len(), 1); assert_eq!(candidates[0].signature, "npm-cacache"); assert_eq!(candidates[0].bytes, 5); + let approval_phrase = proven_cache_trash_approval_phrase(tmp.path()); + assert!(approval_phrase.starts_with("DiskSage cache-trash purge approval ")); let journal = tmp.path().join("journal.jsonl"); let results = purge_proven_cache_trash(tmp.path(), &journal, 7).unwrap(); @@ -496,6 +526,10 @@ mod tests { let journal_text = fs::read_to_string(journal).unwrap(); assert!(journal_text.contains("permanent_cache_trash_delete")); assert!(journal_text.contains("\"outcome\":\"ok\"")); + assert_ne!( + approval_phrase, + proven_cache_trash_approval_phrase(tmp.path()) + ); } #[test] diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c6c40ddbd..92644477e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -719,14 +719,30 @@ pub fn list_proven_cache_trash() -> Result Result { + let bases = rules::BaseDirs::from_env().ok_or("cache-base-directories-unavailable")?; + Ok(crate::cache_cleanup::proven_cache_trash_approval_phrase( + &bases.home, + )) +} + /// Permanently remove only the reviewed, structurally proven regenerable cache entries in Trash. /// The journal records each revalidated item and the operation is never a general Trash purge. #[cfg(not(coverage))] #[tauri::command] pub fn purge_proven_cache_trash( app: AppHandle, + confirmation_phrase: String, ) -> Result { let bases = rules::BaseDirs::from_env().ok_or("cache-base-directories-unavailable")?; + if confirmation_phrase + != crate::cache_cleanup::proven_cache_trash_approval_phrase(&bases.home) + { + return Err("cache-trash-confirmation-mismatch".into()); + } let journal_path = journal_file_path(&app)?; let before = crate::volume_pressure::snapshot_volume(&bases.home, now_ms()).ok(); let items = crate::cache_cleanup::purge_proven_cache_trash( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2d83272f7..55c16f055 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -112,6 +112,7 @@ pub fn run() { commands::list_cache_candidates, commands::clean_regenerable_caches, commands::list_proven_cache_trash, + commands::proven_cache_trash_approval_phrase, commands::purge_proven_cache_trash, cache_cleanup::list_cache_targets, commands::list_dev_artifacts, diff --git a/src/lib/Cleanup.svelte b/src/lib/Cleanup.svelte index 1ae6900a9..c68cc6e28 100644 --- a/src/lib/Cleanup.svelte +++ b/src/lib/Cleanup.svelte @@ -11,6 +11,7 @@ let caches: api.CacheCandidate[] = $state([]); let cacheTrash: api.CacheTrashCandidate[] = $state([]); + let cacheTrashApprovalPhrase = $state(null); let cacheTrashExecution: api.CacheTrashPurgeExecution | null = $state(null); let artifacts: api.DevArtifact[] = $state([]); let selected: Set = $state(new Set()); @@ -42,7 +43,11 @@ loadError = ""; try { caches = await api.listCacheCandidates(); + cacheTrashApprovalPhrase = null; cacheTrash = await api.listProvenCacheTrash(); + if (cacheTrash.length > 0) { + cacheTrashApprovalPhrase = await api.provenCacheTrashApprovalPhrase(); + } artifacts = scannedRoot ? await api.listDevArtifacts(scannedRoot) : []; loadVerdicts(artifacts.map((a) => a.path)); } catch (e) { @@ -111,7 +116,7 @@ const targetBytes = targets.reduce((sum, target) => sum + target.bytes, 0); const okay = await confirm( `${candidate.label}์˜ ์ง๊ณ„ ์บ์‹œ ${targets.length}๊ฐœ(${fmtBytes(targetBytes)})๋ฅผ ํœด์ง€ํ†ต์œผ๋กœ ๋ณด๋ƒ…๋‹ˆ๋‹ค.\n\n` + - "์บ์‹œ ๋ฃจํŠธ๋Š” ๋ณด์กดํ•˜๋ฉฐ, ๊ฐ ํ•ญ๋ชฉ์€ ๊ฐ์ฒด ์ง€๋ฌธยทํฌ๊ธฐยท์ˆ˜์ •์‹œ๊ฐยทactive-use๋ฅผ ๋‹ค์‹œ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค. ์‚ฌ์šฉ ์ค‘์ด๊ฑฐ๋‚˜ ์ฆ๋ช…์ด ๋ถˆ์™„์ „ํ•œ ํ•ญ๋ชฉ์€ ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค. ํœด์ง€ํ†ต์—์„œ ๋ณต์›ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", + "์บ์‹œ ์œ„์น˜๋Š” ๋ณด์กดํ•˜๋ฉฐ, ๊ฐ ํ•ญ๋ชฉ์˜ ํฌ๊ธฐ์™€ ์ˆ˜์ • ์‹œ๊ฐ์„ ๋‹ค์‹œ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. ์‚ฌ์šฉ ์ค‘์ด๊ฑฐ๋‚˜ ์•ˆ์ „ํ•˜๊ฒŒ ํ™•์ธ๋˜์ง€ ์•Š์€ ํ•ญ๋ชฉ์€ ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค. ํœด์ง€ํ†ต์—์„œ ๋ณต์›ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", { title: "DiskSage", kind: "warning" }, ); if (!okay) return; @@ -145,22 +150,29 @@ } async function purgeProvenCacheTrash() { - if (busy || cacheTrash.length === 0) return; + if (busy || cacheTrash.length === 0 || cacheTrashApprovalPhrase === null) return; const bytes = cacheTrash.reduce((sum, candidate) => sum + candidate.bytes, 0); const okay = await confirm( `ํœด์ง€ํ†ต์— ๋‚จ์•„ ์žˆ๋Š” ์žฌ์ƒ์„ฑ ๊ฐ€๋Šฅํ•œ ์บ์‹œ ${cacheTrash.length}๊ฐœ(${fmtBytes(bytes)})๋ฅผ ์˜๊ตฌ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.\n\n` + "์ด ํ•ญ๋ชฉ์€ ๋ณต์›ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž ํŒŒ์ผ๊ณผ ๋‹ค๋ฅธ ํœด์ง€ํ†ต ํ•ญ๋ชฉ์€ ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", { title: "DiskSage ํœด์ง€ํ†ต ์ •๋ฆฌ", kind: "warning" }, ); - if (!okay) return; + const approvalPhrase = cacheTrashApprovalPhrase; + if (!okay || approvalPhrase === null) return; busy = true; loadError = ""; cacheTrashExecution = null; try { - cacheTrashExecution = await api.purgeProvenCacheTrash(); + cacheTrashExecution = await api.purgeProvenCacheTrash(approvalPhrase); await load(); } catch (e) { - loadError = String(e); + if (String(e).includes("cache-trash-confirmation-mismatch")) { + cacheTrashApprovalPhrase = null; + await load(); + loadError = "ํœด์ง€ํ†ต ๋‚ด์šฉ์ด ๋ฐ”๋€Œ์–ด ์ตœ์‹  ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์™”์Šต๋‹ˆ๋‹ค. ๋ชฉ๋ก์„ ํ™•์ธํ•œ ๋’ค ๋‹ค์‹œ ์‹œ๋„ํ•˜์„ธ์š”."; + } else { + loadError = "์žฌ์ƒ์„ฑ ์บ์‹œ๋ฅผ ์˜๊ตฌ ์‚ญ์ œํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ๋ชฉ๋ก์„ ๋‹ค์‹œ ํ™•์ธํ•œ ๋’ค ์žฌ์‹œ๋„ํ•˜์„ธ์š”."; + } } finally { busy = false; } @@ -221,13 +233,13 @@

    ์บ์‹œ

    - ์•Œ๋ ค์ง„ ์บ์‹œ ๋ฃจํŠธ์˜ ์ง๊ณ„ ํ•ญ๋ชฉ๋งŒ ๊ฐ์ฒด ์ง€๋ฌธยทํฌ๊ธฐยท์ˆ˜์ •์‹œ๊ฐ์„ ์žฌ๊ฒ€์ฆํ•œ ๋’ค ํœด์ง€ํ†ต์œผ๋กœ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ์บ์‹œ ๋ฃจํŠธ ์ž์ฒด๋Š” ๋ณด์กด๋ฉ๋‹ˆ๋‹ค. + ์•Œ๋ ค์ง„ ์บ์‹œ ์œ„์น˜ ์•ˆ์˜ ํ•ญ๋ชฉ๋งŒ ํฌ๊ธฐ์™€ ์ˆ˜์ • ์‹œ๊ฐ์„ ๋‹ค์‹œ ํ™•์ธํ•œ ๋’ค ํœด์ง€ํ†ต์œผ๋กœ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ์บ์‹œ ์œ„์น˜ ์ž์ฒด๋Š” ๋ณด์กด๋ฉ๋‹ˆ๋‹ค.

    - npmยทpnpmยทAdobeยทEdgeยทuvยทTrivy ์บ์‹œ๋งŒ ๋Œ€์ƒ์œผ๋กœ ํ•˜๋ฉฐ, ์‚ฌ์šฉ ์ค‘์ด๊ฑฐ๋‚˜ ์ฆ๊ฑฐ๊ฐ€ ๋ฐ”๋€ ํ•ญ๋ชฉ์€ ์ž๋™์œผ๋กœ ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค. + ์žฌ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋Š” ์บ์‹œ๋งŒ ๋Œ€์ƒ์œผ๋กœ ํ•˜๋ฉฐ, ์‚ฌ์šฉ ์ค‘์ด๊ฑฐ๋‚˜ ์ƒํƒœ๊ฐ€ ๋ฐ”๋€ ํ•ญ๋ชฉ์€ ์ž๋™์œผ๋กœ ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค.

    {#if cacheTrash.length > 0}
    @@ -235,8 +247,8 @@ ํœด์ง€ํ†ต์— ๋‚จ์€ ์žฌ์ƒ์„ฑ ๊ฐ€๋Šฅํ•œ ์บ์‹œ {cacheTrash.length}๊ฐœ({fmtBytes(cacheTrash.reduce((sum, item) => sum + item.bytes, 0))})๊ฐ€ ํ™•์ธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์˜๊ตฌ ์‚ญ์ œํ•˜๋ฉด ์‹ค์ œ ์ €์žฅ ๊ณต๊ฐ„์„ ํšŒ์ˆ˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

    -
    {/if} @@ -245,9 +257,13 @@

    ์žฌ์ƒ์„ฑ ์บ์‹œ {purged}/{cacheTrashExecution.items.length}๊ฐœ๋ฅผ ์˜๊ตฌ ์‚ญ์ œํ–ˆ์Šต๋‹ˆ๋‹ค. {cacheTrashExecution.observed_available_gain_bytes === null - ? "์ „ํ›„ ์ €์žฅ ๊ณต๊ฐ„์€ ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค." + ? "์ €์žฅ ๊ณต๊ฐ„ ๋ณ€ํ™”๋Š” ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ์‹œ์Šคํ…œ ์ €์žฅ ๊ณต๊ฐ„์—์„œ ์ง์ ‘ ํ™•์ธํ•˜์„ธ์š”." : `๊ฐ€์šฉ ๊ณต๊ฐ„ ์ฆ๊ฐ€ ${fmtBytes(cacheTrashExecution.observed_available_gain_bytes)}์ž…๋‹ˆ๋‹ค.`} - ์‹คํŒจํ•œ ํ•ญ๋ชฉ์€ ๋‹ค์‹œ ํ™•์ธํ•˜์‹ญ์‹œ์˜ค. + {#if purged < cacheTrashExecution.items.length} + ์‚ญ์ œ๋˜์ง€ ์•Š์€ ํ•ญ๋ชฉ์€ ์œ„ ๋ชฉ๋ก์—์„œ ํ™•์ธํ•œ ๋’ค ๋‹ค์‹œ ์‹œ๋„ํ•˜์‹ญ์‹œ์˜ค. + {:else} + ๋ชจ๋“  ์žฌ์ƒ์„ฑ ์บ์‹œ๋ฅผ ์˜๊ตฌ ์‚ญ์ œํ–ˆ์Šต๋‹ˆ๋‹ค. + {/if}

    {/if} {#if cacheRetryMessage}

    {cacheRetryMessage}

    {/if} diff --git a/src/lib/api.ts b/src/lib/api.ts index efceb09d3..bba1c65d7 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -160,8 +160,10 @@ export const cleanRegenerableCaches = () => invoke("clean_regenerable_caches"); export const listProvenCacheTrash = () => invoke("list_proven_cache_trash"); -export const purgeProvenCacheTrash = () => - invoke("purge_proven_cache_trash"); +export const provenCacheTrashApprovalPhrase = () => + invoke("proven_cache_trash_approval_phrase"); +export const purgeProvenCacheTrash = (confirmationPhrase: string) => + invoke("purge_proven_cache_trash", { confirmationPhrase }); export const listCacheTargets = (dir: string) => invoke("list_cache_targets", { dir }); export const cleanCacheContents = (dir: string, targets: CacheTarget[]) => diff --git a/src/lib/cacheCleanupFlowContract.test.ts b/src/lib/cacheCleanupFlowContract.test.ts index f1c1a8441..cea7f08da 100644 --- a/src/lib/cacheCleanupFlowContract.test.ts +++ b/src/lib/cacheCleanupFlowContract.test.ts @@ -19,17 +19,24 @@ describe("cache cleanup execution boundary", () => { expect(cleanup).toContain("api.cleanCacheContents(candidate.path, targets)"); expect(cleanup).toContain("api.cleanRegenerableCaches()"); expect(cleanup).toContain("api.listProvenCacheTrash()"); - expect(cleanup).toContain("api.purgeProvenCacheTrash()"); + expect(cleanup).toContain("api.provenCacheTrashApprovalPhrase()"); + expect(cleanup).toContain("api.purgeProvenCacheTrash(approvalPhrase)"); + expect(cleanup).toContain("cache-trash-confirmation-mismatch"); + expect(cleanup).toContain("ํœด์ง€ํ†ต ๋‚ด์šฉ์ด ๋ฐ”๋€Œ์–ด ์ตœ์‹  ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์™”์Šต๋‹ˆ๋‹ค"); expect(cleanup).toContain("observed_available_gain_bytes"); - expect(cleanup).toContain("๊ฐ์ฒด ์ง€๋ฌธยทํฌ๊ธฐยท์ˆ˜์ •์‹œ๊ฐ"); - expect(cleanup).toContain("npmยทpnpmยทAdobeยทEdgeยทuvยทTrivy ์บ์‹œ๋งŒ ๋Œ€์ƒ์œผ๋กœ"); + expect(cleanup).toContain("๊ฐ ํ•ญ๋ชฉ์˜ ํฌ๊ธฐ์™€ ์ˆ˜์ • ์‹œ๊ฐ์„ ๋‹ค์‹œ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค"); + expect(cleanup).toContain("์žฌ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋Š” ์บ์‹œ๋งŒ ๋Œ€์ƒ์œผ๋กœ ํ•˜๋ฉฐ"); + expect(cleanup).not.toContain("active-use"); + expect(cleanup).not.toContain("์ฆ๊ฑฐ๊ฐ€ ๋ฐ”๋€ ํ•ญ๋ชฉ"); expect(backend).toContain("pub fn clean_cache_contents("); expect(backend).toContain("cache-cleanup-targets-stale"); expect(backend).toContain("trash_delete_if_identity("); + expect(backend).toContain("pub fn proven_cache_trash_approval_phrase("); expect(tauri).toContain("cache_cleanup::clean_cache_contents"); expect(tauri).toContain("cache_cleanup::list_cache_targets"); expect(tauri).toContain("commands::clean_regenerable_caches"); expect(tauri).toContain("commands::list_proven_cache_trash"); + expect(tauri).toContain("commands::proven_cache_trash_approval_phrase"); expect(tauri).toContain("commands::purge_proven_cache_trash"); }); diff --git a/src/lib/cacheCleanupReadOnlyUiContract.test.ts b/src/lib/cacheCleanupReadOnlyUiContract.test.ts index 54ca7b335..2eb5b1d9d 100644 --- a/src/lib/cacheCleanupReadOnlyUiContract.test.ts +++ b/src/lib/cacheCleanupReadOnlyUiContract.test.ts @@ -16,6 +16,7 @@ describe("cache cleanup fail-closed UX", () => { expect(cleanup).toContain("cleanCacheContents"); expect(cleanup).not.toContain("selectedRules"); expect(cleanup).toContain('role="status"'); - expect(cleanup).toContain("๊ฐ์ฒด ์ง€๋ฌธยทํฌ๊ธฐยท์ˆ˜์ •์‹œ๊ฐ"); + expect(cleanup).toContain("๊ฐ ํ•ญ๋ชฉ์˜ ํฌ๊ธฐ์™€ ์ˆ˜์ • ์‹œ๊ฐ์„ ๋‹ค์‹œ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค"); + expect(cleanup).not.toContain("active-use"); }); }); From 1979cb845b36b3a05378e49b14b7ef5769cf7382 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 12:15:57 +0900 Subject: [PATCH 007/138] docs: track cache purge approval gate --- docs/product-technical-gap-baseline.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 5cbb05e45..3db07d220 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -167,8 +167,8 @@ At each scheduled or operator loop, update this file only with new dated evidenc ## 2026-08-26 physical reclaim loop -- PR #263 implementation head (`080274fc0b99017bda47bd7d17d18ac2698691e9`, base - `2a727dab04cd6bd73c68d75c54c3e1563bcb16ca`; documentation follow-up `baaa99f67e9d14894cd4268d0b06f65692bdb214`) +- PR #263 current implementation head (`9a2a7fc7a3b5a46763f9eafcb0c658edc93bfa53`, base + `2a727dab04cd6bd73c68d75c54c3e1563bcb16ca`) closes the physical-reclaim gap after cache cleanup: the desktop cleanup screen lists only regenerable cache entries already in OS Trash, requires a native confirmation, permanently removes only those revalidated entries, and reports the observed before/after available-space From 40588f4e25863872ab00df4d87f82378ae2c95c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 12:37:52 +0900 Subject: [PATCH 008/138] test: cover pending cache journal failure --- src-tauri/src/cache_cleanup.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src-tauri/src/cache_cleanup.rs b/src-tauri/src/cache_cleanup.rs index 6bad33ef8..b2a199fc9 100644 --- a/src-tauri/src/cache_cleanup.rs +++ b/src-tauri/src/cache_cleanup.rs @@ -545,6 +545,26 @@ mod tests { ); } + #[test] + fn pending_journal_failure_is_returned_without_deleting() { + let tmp = tempfile::tempdir().unwrap(); + let trash = tmp.path().join(".Trash"); + fs::create_dir(&trash).unwrap(); + let npm = trash.join("_cacache"); + fs::create_dir_all(npm.join("content-v2")).unwrap(); + fs::create_dir(npm.join("tmp")).unwrap(); + fs::write(npm.join("content-v2").join("entry"), b"cache").unwrap(); + let journal_directory = tmp.path().join("journal-directory"); + fs::create_dir(&journal_directory).unwrap(); + + let results = purge_proven_cache_trash(tmp.path(), &journal_directory, 7).unwrap(); + + assert_eq!(results.len(), 1); + assert!(!results[0].purged); + assert!(results[0].error.starts_with("journal-write-failed:")); + assert!(npm.exists()); + } + #[cfg(unix)] #[test] fn cleanup_rejects_symlinked_catalog_root_without_touching_outside_data() { From 7108266ce6defca54ad49f62c8cba1ca787db472 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 12:40:03 +0900 Subject: [PATCH 009/138] docs: record customer cache guidance --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec6f5dc45..cfade9a5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and that item instead of hiding an already-completed removal behind an all-or-nothing error. - Bind the desktop purge action to the current proven-Trash candidate set so a changed list is rejected before deletion; pending journal failures now remain visible per item as well. +- Keep cache cleanup guidance focused on what the operator can do next without exposing internal + identity or activity-check terminology. - Keep coverage builds compile-safe by applying the same `not(coverage)` boundary to native-copy identity cleanup and dependent eviction helpers; the focused authority contract remains green. - Add durable private failure records in a separate journal directory and a receipt-bound From 76245166af00a13d5971f1cd13d15645d6017cd9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 21:06:59 -0700 Subject: [PATCH 010/138] test: bind cache purge to reviewed snapshot --- ...ache_trash_approval_snapshot_regression.rs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src-tauri/tests/cache_trash_approval_snapshot_regression.rs diff --git a/src-tauri/tests/cache_trash_approval_snapshot_regression.rs b/src-tauri/tests/cache_trash_approval_snapshot_regression.rs new file mode 100644 index 000000000..2e1cc9e92 --- /dev/null +++ b/src-tauri/tests/cache_trash_approval_snapshot_regression.rs @@ -0,0 +1,51 @@ +use std::fs; + +use disksage_lib::cache_cleanup::{ + proven_cache_trash_approval_phrase, proven_cache_trash_candidates, purge_proven_cache_trash, +}; + +fn create_npm_cache(trash: &std::path::Path) { + let cache = trash.join("_cacache"); + fs::create_dir_all(cache.join("content-v2")).unwrap(); + fs::create_dir(cache.join("tmp")).unwrap(); + fs::write(cache.join("content-v2").join("entry"), b"cache").unwrap(); +} + +fn create_trivy_cache(trash: &std::path::Path) { + let cache = trash.join("db"); + fs::create_dir(&cache).unwrap(); + fs::write(cache.join("trivy.db"), b"db").unwrap(); + fs::write(cache.join("metadata.json"), b"{}").unwrap(); +} + +#[test] +fn purge_never_deletes_candidate_added_after_reviewed_approval_snapshot() { + let home = tempfile::tempdir().unwrap(); + let trash = home.path().join(".Trash"); + fs::create_dir(&trash).unwrap(); + create_npm_cache(&trash); + + let reviewed = proven_cache_trash_candidates(home.path()); + assert_eq!(reviewed.len(), 1); + assert_eq!(reviewed[0].name, "_cacache"); + let reviewed_phrase = proven_cache_trash_approval_phrase(home.path()); + + // This second structurally valid cache appears only after the operator-reviewed snapshot. + // A safe purge may revalidate reviewed objects, but it must never expand deletion authority + // by rescanning and deleting this newly appeared candidate. + create_trivy_cache(&trash); + assert_ne!(reviewed_phrase, proven_cache_trash_approval_phrase(home.path())); + + let journal = home.path().join("journal.jsonl"); + let results = purge_proven_cache_trash(home.path(), &journal, 7).unwrap(); + + assert!(results.iter().any(|item| item.name == "_cacache" && item.purged)); + assert!( + !results.iter().any(|item| item.name == "db" && item.purged), + "purge must not authorize a structurally valid cache that appeared after the reviewed snapshot" + ); + assert!( + trash.join("db").exists(), + "the unreviewed cache candidate must remain in Trash" + ); +} From 49443f05bd5599c4f72c933ecb5f584ccc125955 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 21:08:30 -0700 Subject: [PATCH 011/138] test: expose cache purge audit failures --- src/lib/cacheTrashPurgeSummary.test.ts | 57 ++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/lib/cacheTrashPurgeSummary.test.ts diff --git a/src/lib/cacheTrashPurgeSummary.test.ts b/src/lib/cacheTrashPurgeSummary.test.ts new file mode 100644 index 000000000..1c790b0f8 --- /dev/null +++ b/src/lib/cacheTrashPurgeSummary.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { summarizeCacheTrashPurge } from "./cacheTrashPurgeSummary"; + +describe("summarizeCacheTrashPurge", () => { + it("never counts a physically deleted item with a journal error as clean success", () => { + const summary = summarizeCacheTrashPurge([ + { + name: "_cacache", + path: "/private/Trash/_cacache", + bytes: 10, + signature: "npm-cacache", + purged: true, + error: "purged-but-journal-write-failed:disk-full", + }, + { + name: "db", + path: "/private/Trash/db", + bytes: 20, + signature: "trivy-database-cache", + purged: true, + error: "", + }, + { + name: "v11", + path: "/private/Trash/v11", + bytes: 30, + signature: "pnpm-store-v11", + purged: false, + error: "cache-trash-signature-changed", + }, + ]); + + expect(summary.successfulCount).toBe(1); + expect(summary.allSucceeded).toBe(false); + expect(summary.errors).toEqual([ + "_cacache: purged-but-journal-write-failed:disk-full", + "v11: cache-trash-signature-changed", + ]); + }); + + it("reports allSucceeded only when every deletion is purged and audit-clean", () => { + const summary = summarizeCacheTrashPurge([ + { + name: "db", + path: "/private/Trash/db", + bytes: 20, + signature: "trivy-database-cache", + purged: true, + error: "", + }, + ]); + + expect(summary.successfulCount).toBe(1); + expect(summary.errors).toEqual([]); + expect(summary.allSucceeded).toBe(true); + }); +}); From 31b291a4fa44e8c8545fdd1789b726aa55fbe836 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 21:08:40 -0700 Subject: [PATCH 012/138] fix: keep cache purge audit failures visible --- src/lib/cacheTrashPurgeSummary.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/lib/cacheTrashPurgeSummary.ts diff --git a/src/lib/cacheTrashPurgeSummary.ts b/src/lib/cacheTrashPurgeSummary.ts new file mode 100644 index 000000000..f10281975 --- /dev/null +++ b/src/lib/cacheTrashPurgeSummary.ts @@ -0,0 +1,24 @@ +import type { CacheTrashPurgeResult } from "./api"; + +export interface CacheTrashPurgeSummary { + successfulCount: number; + allSucceeded: boolean; + errors: string[]; +} + +/** + * Summarize physical cache-trash deletion without treating an audit-journal gap as success. + * A physically removed item is cleanly successful only when the backend also reports no error. + */ +export function summarizeCacheTrashPurge(items: CacheTrashPurgeResult[]): CacheTrashPurgeSummary { + const successfulCount = items.filter((item) => item.purged && item.error.length === 0).length; + const errors = items + .filter((item) => item.error.length > 0) + .map((item) => `${item.name}: ${item.error}`); + + return { + successfulCount, + allSucceeded: items.length > 0 && successfulCount === items.length && errors.length === 0, + errors, + }; +} From 9760a171b0f326a0646934f0b3bf1b6f8b5ee87d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 21:09:51 -0700 Subject: [PATCH 013/138] fix: surface cache purge audit gaps --- src/lib/Cleanup.svelte | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/lib/Cleanup.svelte b/src/lib/Cleanup.svelte index c68cc6e28..ed9419662 100644 --- a/src/lib/Cleanup.svelte +++ b/src/lib/Cleanup.svelte @@ -2,6 +2,7 @@ import * as api from "./api"; import { fmtBytes } from "./fmt"; import { verdictBadge } from "./verdictBadge"; + import { summarizeCacheTrashPurge } from "./cacheTrashPurgeSummary"; import { confirm } from "@tauri-apps/plugin-dialog"; import GitWorktreeCleanup from "./GitWorktreeCleanup.svelte"; import BrewCleanup from "./BrewCleanup.svelte"; @@ -253,18 +254,25 @@ {/if} {#if cacheTrashExecution} - {@const purged = cacheTrashExecution.items.filter((item) => item.purged).length} + {@const purgeSummary = summarizeCacheTrashPurge(cacheTrashExecution.items)}

    - ์žฌ์ƒ์„ฑ ์บ์‹œ {purged}/{cacheTrashExecution.items.length}๊ฐœ๋ฅผ ์˜๊ตฌ ์‚ญ์ œํ–ˆ์Šต๋‹ˆ๋‹ค. + ์žฌ์ƒ์„ฑ ์บ์‹œ {purgeSummary.successfulCount}/{cacheTrashExecution.items.length}๊ฐœ๋ฅผ ์˜๊ตฌ ์‚ญ์ œํ•˜๊ณ  ๊ฐ์‚ฌ ๊ธฐ๋ก๊นŒ์ง€ ์™„๋ฃŒํ–ˆ์Šต๋‹ˆ๋‹ค. {cacheTrashExecution.observed_available_gain_bytes === null ? "์ €์žฅ ๊ณต๊ฐ„ ๋ณ€ํ™”๋Š” ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ์‹œ์Šคํ…œ ์ €์žฅ ๊ณต๊ฐ„์—์„œ ์ง์ ‘ ํ™•์ธํ•˜์„ธ์š”." : `๊ฐ€์šฉ ๊ณต๊ฐ„ ์ฆ๊ฐ€ ${fmtBytes(cacheTrashExecution.observed_available_gain_bytes)}์ž…๋‹ˆ๋‹ค.`} - {#if purged < cacheTrashExecution.items.length} - ์‚ญ์ œ๋˜์ง€ ์•Š์€ ํ•ญ๋ชฉ์€ ์œ„ ๋ชฉ๋ก์—์„œ ํ™•์ธํ•œ ๋’ค ๋‹ค์‹œ ์‹œ๋„ํ•˜์‹ญ์‹œ์˜ค. + {#if purgeSummary.allSucceeded} + ๋ชจ๋“  ์žฌ์ƒ์„ฑ ์บ์‹œ๋ฅผ ์˜๊ตฌ ์‚ญ์ œํ•˜๊ณ  ๊ฐ์‚ฌ ๊ธฐ๋ก๊นŒ์ง€ ์™„๋ฃŒํ–ˆ์Šต๋‹ˆ๋‹ค. {:else} - ๋ชจ๋“  ์žฌ์ƒ์„ฑ ์บ์‹œ๋ฅผ ์˜๊ตฌ ์‚ญ์ œํ–ˆ์Šต๋‹ˆ๋‹ค. + ์™„๋ฃŒ๋˜์ง€ ์•Š์€ ํ•ญ๋ชฉ์€ ์•„๋ž˜ ๊ฒฐ๊ณผ๋ฅผ ํ™•์ธํ•œ ๋’ค ๋‹ค์‹œ ์‹œ๋„ํ•˜์‹ญ์‹œ์˜ค. {/if}

    + {#if purgeSummary.errors.length > 0} +
      + {#each purgeSummary.errors as error} +
    • {error}
    • + {/each} +
    + {/if} {/if} {#if cacheRetryMessage}

    {cacheRetryMessage}

    {/if}
      @@ -418,4 +426,4 @@ .badge-caution { background: #b8860b; } .badge-keep { background: #b03030; } .badge-unrated { background: #888; } - + \ No newline at end of file From 4f6d7efe959bfb69021af406390128e8ac47fc2b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 21:15:04 -0700 Subject: [PATCH 014/138] fix: bind cache purge to reviewed snapshot --- src-tauri/src/cache_trash_reclaim.rs | 276 +++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 src-tauri/src/cache_trash_reclaim.rs diff --git a/src-tauri/src/cache_trash_reclaim.rs b/src-tauri/src/cache_trash_reclaim.rs new file mode 100644 index 000000000..67ba4c40e --- /dev/null +++ b/src-tauri/src/cache_trash_reclaim.rs @@ -0,0 +1,276 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use crate::cache_cleanup::{CacheTrashCandidate, CacheTrashPurgeExecution, CacheTrashPurgeResult}; + +const REVIEW_SCHEMA_KIND: &str = "disksage.cache-trash-review"; +const REVIEW_SCHEMA_VERSION: u32 = 1; +const MAX_APPROVED_CANDIDATES: usize = 9; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CacheTrashReview { + pub schema_kind: String, + pub schema_version: u32, + pub supported: bool, + pub candidates: Vec, + pub approval_phrase: Option, + pub notice: Option, +} + +fn sorted_candidates(candidates: &[CacheTrashCandidate]) -> Vec { + let mut sorted = candidates.to_vec(); + sorted.sort_by(|left, right| left.path.cmp(&right.path)); + sorted +} + +pub fn approval_phrase_for_candidates(candidates: &[CacheTrashCandidate]) -> String { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"disksage.cache-trash-reviewed-snapshot.v1\0"); + for candidate in sorted_candidates(candidates) { + for field in [candidate.name, candidate.path, candidate.signature] { + hasher.update(&(field.len() as u64).to_le_bytes()); + hasher.update(field.as_bytes()); + } + hasher.update(&candidate.bytes.to_le_bytes()); + } + format!( + "DiskSage cache-trash reviewed snapshot {}", + hasher.finalize().to_hex() + ) +} + +#[cfg(target_os = "macos")] +fn macos_review(home: &Path) -> CacheTrashReview { + let candidates = crate::cache_cleanup::proven_cache_trash_candidates(home); + let approval_phrase = (!candidates.is_empty()).then(|| approval_phrase_for_candidates(&candidates)); + CacheTrashReview { + schema_kind: REVIEW_SCHEMA_KIND.into(), + schema_version: REVIEW_SCHEMA_VERSION, + supported: true, + candidates, + approval_phrase, + notice: None, + } +} + +pub fn review_for_home(home: &Path) -> CacheTrashReview { + #[cfg(target_os = "macos")] + { + macos_review(home) + } + #[cfg(not(target_os = "macos"))] + { + let _ = home; + CacheTrashReview { + schema_kind: REVIEW_SCHEMA_KIND.into(), + schema_version: REVIEW_SCHEMA_VERSION, + supported: false, + candidates: Vec::new(), + approval_phrase: None, + notice: Some("cache-trash-native-discovery-macos-only".into()), + } + } +} + +fn merge_purge_errors(operation_error: Option, journal_error: Option) -> String { + match (operation_error, journal_error) { + (None, None) => String::new(), + (Some(operation), None) => operation, + (None, Some(journal)) => format!("purged-but-journal-write-failed:{journal}"), + (Some(operation), Some(journal)) => { + format!("{operation};journal-write-failed:{journal}") + } + } +} + +fn validate_approved_snapshot(approved: &[CacheTrashCandidate]) -> Result<(), String> { + if approved.is_empty() || approved.len() > MAX_APPROVED_CANDIDATES { + return Err("cache-trash-approved-snapshot-invalid".into()); + } + let mut paths = HashSet::with_capacity(approved.len()); + for candidate in approved { + if candidate.name.is_empty() + || candidate.path.is_empty() + || candidate.signature.is_empty() + || !paths.insert(candidate.path.clone()) + { + return Err("cache-trash-approved-snapshot-invalid".into()); + } + } + Ok(()) +} + +/// Permanently remove only candidates in the operator-reviewed snapshot. +/// +/// The current Trash is rescanned only to revalidate each approved object. Newly appearing proven +/// caches can never expand deletion authority because iteration is over `approved`, not the fresh +/// discovery result. +#[cfg(target_os = "macos")] +pub fn purge_approved_cache_trash( + home: &Path, + approved: &[CacheTrashCandidate], + confirmation_phrase: &str, + journal_path: &Path, + now_ms: u64, +) -> Result, String> { + validate_approved_snapshot(approved)?; + if confirmation_phrase != approval_phrase_for_candidates(approved) { + return Err("cache-trash-confirmation-mismatch".into()); + } + + let mut results = Vec::with_capacity(approved.len()); + for candidate in sorted_candidates(approved) { + let current = crate::cache_cleanup::proven_cache_trash_candidates(home); + let still_exact = current.iter().any(|observed| observed == &candidate); + if !still_exact { + results.push(CacheTrashPurgeResult { + name: candidate.name, + path: candidate.path, + bytes: candidate.bytes, + signature: candidate.signature, + purged: false, + error: "cache-trash-approved-candidate-changed".into(), + }); + continue; + } + + let path = PathBuf::from(&candidate.path); + let mut entry = crate::safety::JournalEntry { + ts_ms: now_ms, + op: "permanent_cache_trash_delete".into(), + path: candidate.path.clone(), + bytes: candidate.bytes, + outcome: "pending".into(), + }; + if let Err(error) = crate::safety::journal_append(journal_path, &entry) { + results.push(CacheTrashPurgeResult { + name: candidate.name, + path: candidate.path, + bytes: candidate.bytes, + signature: candidate.signature, + purged: false, + error: format!("journal-write-failed:{error}"), + }); + continue; + } + + // Re-scan immediately before mutation. Exact equality rechecks path, signature and bounded + // bytes while refusing symlinked or structurally changed candidates through discovery. + let immediately_current = crate::cache_cleanup::proven_cache_trash_candidates(home); + let outcome = if immediately_current.iter().any(|observed| observed == &candidate) { + std::fs::remove_dir_all(&path).map_err(|error| error.to_string()) + } else { + Err("cache-trash-approved-candidate-changed".into()) + }; + entry.outcome = match &outcome { + Ok(()) => "ok".into(), + Err(error) => format!("error:{error}"), + }; + let journal_error = crate::safety::journal_append(journal_path, &entry) + .err() + .map(|error| error.to_string()); + let operation_error = outcome.as_ref().err().cloned(); + results.push(CacheTrashPurgeResult { + name: candidate.name, + path: candidate.path, + bytes: candidate.bytes, + signature: candidate.signature, + purged: outcome.is_ok(), + error: merge_purge_errors(operation_error, journal_error), + }); + } + Ok(results) +} + +#[cfg(not(target_os = "macos"))] +pub fn purge_approved_cache_trash( + _home: &Path, + _approved: &[CacheTrashCandidate], + _confirmation_phrase: &str, + _journal_path: &Path, + _now_ms: u64, +) -> Result, String> { + Err("cache-trash-native-discovery-unsupported".into()) +} + +#[cfg(not(coverage))] +#[tauri::command] +pub fn review_proven_cache_trash() -> Result { + let bases = crate::rules::BaseDirs::from_env().ok_or("cache-base-directories-unavailable")?; + Ok(review_for_home(&bases.home)) +} + +#[cfg(not(coverage))] +#[tauri::command] +pub fn purge_proven_cache_trash( + app: tauri::AppHandle, + approved_candidates: Vec, + confirmation_phrase: String, +) -> Result { + let bases = crate::rules::BaseDirs::from_env().ok_or("cache-base-directories-unavailable")?; + let journal_path = crate::commands::journal_file_path(&app)?; + let before = crate::volume_pressure::snapshot_volume(&bases.home, crate::commands::now_ms()).ok(); + let items = purge_approved_cache_trash( + &bases.home, + &approved_candidates, + &confirmation_phrase, + &journal_path, + crate::commands::now_ms(), + )?; + let after = crate::volume_pressure::snapshot_volume(&bases.home, crate::commands::now_ms()).ok(); + let before_available_bytes = before.as_ref().map(|snapshot| snapshot.available_bytes); + let after_available_bytes = after.as_ref().map(|snapshot| snapshot.available_bytes); + let observed_available_gain_bytes = before_available_bytes + .zip(after_available_bytes) + .and_then(|(before, after)| after.checked_sub(before)); + Ok(CacheTrashPurgeExecution { + schema_kind: "disksage.cache-trash-purge".into(), + schema_version: 1, + items, + before_available_bytes, + after_available_bytes, + observed_available_gain_bytes, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn phrase_is_order_independent_and_binds_candidate_fields() { + let a = CacheTrashCandidate { + name: "_cacache".into(), + path: "/tmp/.Trash/_cacache".into(), + bytes: 10, + signature: "npm-cacache".into(), + }; + let mut b = CacheTrashCandidate { + name: "db".into(), + path: "/tmp/.Trash/db".into(), + bytes: 20, + signature: "trivy-database-cache".into(), + }; + assert_eq!( + approval_phrase_for_candidates(&[a.clone(), b.clone()]), + approval_phrase_for_candidates(&[b.clone(), a.clone()]) + ); + let original = approval_phrase_for_candidates(&[a.clone(), b.clone()]); + b.bytes += 1; + assert_ne!(original, approval_phrase_for_candidates(&[a, b])); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn unsupported_platform_never_pretends_dot_trash_is_native() { + let home = tempfile::tempdir().unwrap(); + std::fs::create_dir(home.path().join(".Trash")).unwrap(); + let review = review_for_home(home.path()); + assert!(!review.supported); + assert!(review.candidates.is_empty()); + assert!(review.approval_phrase.is_none()); + assert_eq!(review.notice.as_deref(), Some("cache-trash-native-discovery-macos-only")); + } +} From 36b2335e49464ca54ebb1489e7c37656757df035 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 21:15:26 -0700 Subject: [PATCH 015/138] fix: route cache purge through reviewed snapshot --- src-tauri/src/lib.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 55c16f055..a53e23a4c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,6 +11,8 @@ mod node_navigation; #[cfg_attr(coverage, allow(dead_code))] pub mod cache_cleanup; #[cfg_attr(coverage, allow(dead_code))] +pub mod cache_trash_reclaim; +#[cfg_attr(coverage, allow(dead_code))] mod scanner; #[cfg_attr(coverage, allow(dead_code))] mod userrules; @@ -111,9 +113,8 @@ pub fn run() { commands::top_files, commands::list_cache_candidates, commands::clean_regenerable_caches, - commands::list_proven_cache_trash, - commands::proven_cache_trash_approval_phrase, - commands::purge_proven_cache_trash, + cache_trash_reclaim::review_proven_cache_trash, + cache_trash_reclaim::purge_proven_cache_trash, cache_cleanup::list_cache_targets, commands::list_dev_artifacts, commands::clean_paths, From 3cd5d2969b713805716b406a259bddc33951d85b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 13:15:38 +0900 Subject: [PATCH 016/138] feat: make disk reclaim and customer copy actionable --- CHANGELOG.md | 3 + README.md | 1 + ...ache-cleanup-is-per-item-evidence-bound.md | 6 + src-tauri/src/bin/disksage-cache-cleanup.rs | 12 +- src-tauri/src/cache_cleanup.rs | 149 +++++- src-tauri/src/commands.rs | 24 +- src-tauri/src/lib.rs | 1 - src/lib/BrewCleanup.svelte | 62 ++- src/lib/Cleanup.svelte | 81 ++-- src/lib/CloudArchive.svelte | 427 +++++++++--------- src/lib/GitWorktreeCleanup.svelte | 59 ++- src/lib/IcloudLocalEviction.svelte | 54 +-- src/lib/Organize.svelte | 47 +- src/lib/OrphanCleanup.svelte | 24 +- src/lib/api.ts | 12 +- src/lib/brewCleanupSafetyUiContract.test.ts | 7 +- src/lib/cacheCleanupFlowContract.test.ts | 9 +- src/lib/cloudArchiveAdmissionContract.test.ts | 46 +- ...gitWorktreeCleanupSafetyUiContract.test.ts | 24 + ...cloudLocalEvictionSafetyUiContract.test.ts | 8 +- src/lib/organizeCustomerCopyContract.test.ts | 21 + src/lib/orphanCleanupContract.test.ts | 6 +- src/lib/podmanCleanupSafetyUiContract.test.ts | 22 + 23 files changed, 655 insertions(+), 450 deletions(-) create mode 100644 src/lib/gitWorktreeCleanupSafetyUiContract.test.ts create mode 100644 src/lib/organizeCustomerCopyContract.test.ts create mode 100644 src/lib/podmanCleanupSafetyUiContract.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cfade9a5b..a5a4a1421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and that item instead of hiding an already-completed removal behind an all-or-nothing error. - Bind the desktop purge action to the current proven-Trash candidate set so a changed list is rejected before deletion; pending journal failures now remain visible per item as well. +- Produce the Trash list and approval phrase as one snapshot, revalidate only that submitted set + immediately before deletion, and support the native Trash directory on macOS/Linux while + returning no candidates on Windows until Recycle Bin identity guarantees are implemented. - Keep cache cleanup guidance focused on what the operator can do next without exposing internal identity or activity-check terminology. - Keep coverage builds compile-safe by applying the same `not(coverage)` boundary to native-copy diff --git a/README.md b/README.md index 5ce1e80a8..590fc99b3 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - ๐Ÿงน **Known cache & temp cleanup** โ€” OS, browser, and package-manager caches - ๐Ÿ—‘๏ธ **Cache Trash recovery** โ€” review regenerable cache items already in Trash, then permanently remove only those items to release local space; user files and other Trash entries stay untouched + (macOS and Linux; Windows remains fail-closed until Recycle Bin identity support is available) - ๐Ÿ›  **Dev artifact cleanup** โ€” stale `node_modules`, `target/`, `venv`, โ€ฆ - ๐Ÿ‘ฏ **Duplicate finder** โ€” size โ†’ partial hash โ†’ BLAKE3 full hash - ๐Ÿ—‚ **Ontology-based organizing** โ€” files classified into an OWL taxonomy you can edit; move plans use a complete bounded scan, bind metadata-first production-time lineage and source size/mtime, revalidate them immediately before moving, and skip File Provider dataless sources diff --git a/docs/architecture/adr/0002-cache-cleanup-is-per-item-evidence-bound.md b/docs/architecture/adr/0002-cache-cleanup-is-per-item-evidence-bound.md index 6d3ca371a..8339d1cf6 100644 --- a/docs/architecture/adr/0002-cache-cleanup-is-per-item-evidence-bound.md +++ b/docs/architecture/adr/0002-cache-cleanup-is-per-item-evidence-bound.md @@ -27,6 +27,12 @@ collected independently for each reviewed child with bounded, path-local `lsof` OS-Trash children whose exact known cache name and structural signature are revalidated, whose bounded tree contains no symlink, and whose deletion is journaled as pending/ok/error. No arbitrary Trash entry, cloud placeholder, or user-file candidate qualifies. +- The desktop list and approval phrase are one read-only `CacheTrashSnapshot`. Permanent cleanup + accepts that submitted snapshot, recomputes its phrase, and revalidates each direct child, + name, structure, and bounded byte count immediately before deletion; it never expands the + approved set by discovering new Trash entries during deletion. macOS uses `~/.Trash`; Linux + uses the freedesktop `~/.local/share/Trash/files` directory. Windows returns no candidates until + a native Recycle Bin implementation can provide the same identity and journal guarantees. This per-item probe is the authoritative cleanup boundary. A live process elsewhere under the same cache root must not prevent reclaiming an independently inactive entry, and it must never be diff --git a/src-tauri/src/bin/disksage-cache-cleanup.rs b/src-tauri/src/bin/disksage-cache-cleanup.rs index 82194a62a..146cb309c 100644 --- a/src-tauri/src/bin/disksage-cache-cleanup.rs +++ b/src-tauri/src/bin/disksage-cache-cleanup.rs @@ -4,7 +4,7 @@ //! identity-bound children of the npm, pnpm, Adobe, Edge, uv, and Trivy cache roots to OS Trash. use disksage_lib::cache_cleanup::{ - clean_regenerable_caches_headless, proven_cache_trash_candidates, purge_proven_cache_trash, + clean_regenerable_caches_headless, proven_cache_trash_snapshot, purge_proven_cache_trash, }; use std::ffi::OsString; use std::path::PathBuf; @@ -109,7 +109,7 @@ fn run_with_args(raw_args: impl IntoIterator) -> Result<(), Str }; if !args.execute { let cache_trash = if args.purge_proven_cache_trash { - serde_json::to_value(proven_cache_trash_candidates(&home_directory()?)) + serde_json::to_value(proven_cache_trash_snapshot(&home_directory()?)) .map_err(|error| error.to_string())? } else { serde_json::Value::Array(Vec::new()) @@ -130,7 +130,13 @@ fn run_with_args(raw_args: impl IntoIterator) -> Result<(), Str std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; } if args.purge_proven_cache_trash { - let results = purge_proven_cache_trash(&home_directory()?, &args.journal_path, now_ms())?; + let snapshot = proven_cache_trash_snapshot(&home_directory()?); + let results = purge_proven_cache_trash( + &home_directory()?, + &args.journal_path, + now_ms(), + &snapshot, + )?; println!( "{}", serde_json::json!({ diff --git a/src-tauri/src/cache_cleanup.rs b/src-tauri/src/cache_cleanup.rs index b2a199fc9..90014c347 100644 --- a/src-tauri/src/cache_cleanup.rs +++ b/src-tauri/src/cache_cleanup.rs @@ -43,6 +43,18 @@ pub struct CacheTrashCandidate { pub signature: String, } +/// Candidate list and approval token produced by one Trash scan. +/// +/// The desktop must submit this exact snapshot for permanent removal. The backend still +/// revalidates every item immediately before deleting it, but never expands the approved set by +/// scanning for new entries during the destructive operation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CacheTrashSnapshot { + pub candidates: Vec, + pub approval_phrase: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CacheTrashPurgeResult { @@ -154,10 +166,33 @@ fn bounded_tree_size(path: &Path, entries: &mut usize) -> Result { Ok(total) } +fn trash_directory(home: &Path) -> Option { + #[cfg(target_os = "macos")] + { + return Some(home.join(".Trash")); + } + #[cfg(all(unix, not(target_os = "macos")))] + { + return Some( + home.join(".local") + .join("share") + .join("Trash") + .join("files"), + ); + } + #[cfg(windows)] + { + let _ = home; + None + } +} + /// Return only direct OS-Trash children whose cache signature is proven by structure and whose /// size can be bounded without following symlinks or reading file contents. pub fn proven_cache_trash_candidates(home: &Path) -> Vec { - let trash = home.join(".Trash"); + let Some(trash) = trash_directory(home) else { + return Vec::new(); + }; let Ok(entries) = std::fs::read_dir(&trash) else { return Vec::new(); }; @@ -186,13 +221,15 @@ pub fn proven_cache_trash_candidates(home: &Path) -> Vec { candidates } -/// Return a candidate-set-bound approval phrase for the desktop confirmation boundary. -/// The phrase is opaque to the customer and changes whenever the proven Trash set changes. -pub fn proven_cache_trash_approval_phrase(home: &Path) -> String { +fn approval_phrase_for_candidates(candidates: &[CacheTrashCandidate]) -> String { let mut hasher = blake3::Hasher::new(); hasher.update(b"disksage.cache-trash-purge-approval.v1\0"); - for candidate in proven_cache_trash_candidates(home) { - for field in [candidate.name, candidate.path, candidate.signature] { + for candidate in candidates { + for field in [ + candidate.name.as_str(), + candidate.path.as_str(), + candidate.signature.as_str(), + ] { hasher.update(&(field.len() as u64).to_le_bytes()); hasher.update(field.as_bytes()); } @@ -204,17 +241,47 @@ pub fn proven_cache_trash_approval_phrase(home: &Path) -> String { ) } +/// Return the candidate list and approval phrase from one atomic read-only scan. +pub fn proven_cache_trash_snapshot(home: &Path) -> CacheTrashSnapshot { + let candidates = proven_cache_trash_candidates(home); + let approval_phrase = approval_phrase_for_candidates(&candidates); + CacheTrashSnapshot { + candidates, + approval_phrase, + } +} + +/// Return a candidate-set-bound approval phrase for the desktop confirmation boundary. +/// The phrase is opaque to the customer and changes whenever the proven Trash set changes. +pub fn proven_cache_trash_approval_phrase(home: &Path) -> String { + proven_cache_trash_snapshot(home).approval_phrase +} + /// Permanently remove only the proven cache directories in OS Trash. The explicit CLI flag is the /// approval boundary; each object is rechecked immediately before removal and journaled. pub fn purge_proven_cache_trash( home: &Path, journal_path: &Path, now_ms: u64, + snapshot: &CacheTrashSnapshot, ) -> Result, String> { - let planned = proven_cache_trash_candidates(home); + if snapshot.approval_phrase != approval_phrase_for_candidates(&snapshot.candidates) { + return Err("cache-trash-confirmation-mismatch".into()); + } + let Some(trash) = trash_directory(home) else { + return Ok(Vec::new()); + }; + let planned = snapshot.candidates.clone(); let mut results = Vec::with_capacity(planned.len()); for candidate in planned { let path = PathBuf::from(&candidate.path); + let path_is_direct_trash_child = path.parent() == Some(trash.as_path()) + && path + .file_name() + .is_some_and(|name| name.to_string_lossy() == candidate.name) + && !path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)); let mut entry = crate::safety::JournalEntry { ts_ms: now_ms, op: "permanent_cache_trash_delete".into(), @@ -233,8 +300,13 @@ pub fn purge_proven_cache_trash( }); continue; } - let outcome = if looks_like_proven_cache_trash(&path, &candidate.name) - .is_some_and(|signature| signature == candidate.signature) + let mut count = 0; + let unchanged_size = + bounded_tree_size(&path, &mut count).is_ok_and(|bytes| bytes == candidate.bytes); + let outcome = if path_is_direct_trash_child + && unchanged_size + && looks_like_proven_cache_trash(&path, &candidate.name) + .is_some_and(|signature| signature == candidate.signature) { match std::fs::remove_dir_all(&path) { Ok(()) => Ok(()), @@ -498,10 +570,11 @@ mod tests { ); } + #[cfg(not(windows))] #[test] fn proven_cache_trash_requires_signature_and_journals_purge() { let tmp = tempfile::tempdir().unwrap(); - let trash = tmp.path().join(".Trash"); + let trash = trash_directory(tmp.path()).unwrap(); fs::create_dir(&trash).unwrap(); let npm = trash.join("_cacache"); fs::create_dir_all(npm.join("content-v2")).unwrap(); @@ -519,7 +592,8 @@ mod tests { assert!(approval_phrase.starts_with("DiskSage cache-trash purge approval ")); let journal = tmp.path().join("journal.jsonl"); - let results = purge_proven_cache_trash(tmp.path(), &journal, 7).unwrap(); + let snapshot = proven_cache_trash_snapshot(tmp.path()); + let results = purge_proven_cache_trash(tmp.path(), &journal, 7, &snapshot).unwrap(); assert_eq!(results.len(), 1); assert!(results[0].purged); assert!(!npm.exists()); @@ -545,10 +619,11 @@ mod tests { ); } + #[cfg(not(windows))] #[test] fn pending_journal_failure_is_returned_without_deleting() { let tmp = tempfile::tempdir().unwrap(); - let trash = tmp.path().join(".Trash"); + let trash = trash_directory(tmp.path()).unwrap(); fs::create_dir(&trash).unwrap(); let npm = trash.join("_cacache"); fs::create_dir_all(npm.join("content-v2")).unwrap(); @@ -557,7 +632,9 @@ mod tests { let journal_directory = tmp.path().join("journal-directory"); fs::create_dir(&journal_directory).unwrap(); - let results = purge_proven_cache_trash(tmp.path(), &journal_directory, 7).unwrap(); + let snapshot = proven_cache_trash_snapshot(tmp.path()); + let results = + purge_proven_cache_trash(tmp.path(), &journal_directory, 7, &snapshot).unwrap(); assert_eq!(results.len(), 1); assert!(!results[0].purged); @@ -565,6 +642,52 @@ mod tests { assert!(npm.exists()); } + #[cfg(not(windows))] + #[test] + fn purge_never_expands_beyond_submitted_snapshot() { + let tmp = tempfile::tempdir().unwrap(); + let trash = trash_directory(tmp.path()).unwrap(); + fs::create_dir_all(&trash).unwrap(); + let npm = trash.join("_cacache"); + fs::create_dir_all(npm.join("content-v2")).unwrap(); + fs::create_dir(npm.join("tmp")).unwrap(); + fs::write(npm.join("content-v2").join("entry"), b"cache").unwrap(); + let snapshot = proven_cache_trash_snapshot(tmp.path()); + + let pnpm = trash.join("v11"); + fs::create_dir(&pnpm).unwrap(); + fs::create_dir(pnpm.join("metadata")).unwrap(); + fs::create_dir(pnpm.join("metadata-full")).unwrap(); + + let journal = tmp.path().join("journal.jsonl"); + let results = purge_proven_cache_trash(tmp.path(), &journal, 7, &snapshot).unwrap(); + assert_eq!(results.len(), 1); + assert!(results[0].purged); + assert!(!npm.exists()); + assert!(pnpm.exists(), "entries added after approval must remain"); + } + + #[cfg(not(windows))] + #[test] + fn purge_rejects_tampered_snapshot_before_journaling_or_deletion() { + let tmp = tempfile::tempdir().unwrap(); + let trash = trash_directory(tmp.path()).unwrap(); + fs::create_dir_all(&trash).unwrap(); + let npm = trash.join("_cacache"); + fs::create_dir_all(npm.join("content-v2")).unwrap(); + fs::create_dir(npm.join("tmp")).unwrap(); + fs::write(npm.join("content-v2").join("entry"), b"cache").unwrap(); + + let mut snapshot = proven_cache_trash_snapshot(tmp.path()); + snapshot.approval_phrase.push_str("-changed"); + let journal = tmp.path().join("journal.jsonl"); + let error = purge_proven_cache_trash(tmp.path(), &journal, 7, &snapshot).unwrap_err(); + + assert_eq!(error, "cache-trash-confirmation-mismatch"); + assert!(npm.exists()); + assert!(!journal.exists()); + } + #[cfg(unix)] #[test] fn cleanup_rejects_symlinked_catalog_root_without_touching_outside_data() { diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 92644477e..771912cca 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -710,23 +710,13 @@ pub fn clean_regenerable_caches(app: AppHandle) -> Result, Stri )) } -/// Return only structurally proven, regenerable cache directories already in the user's Trash. +/// Return the structurally proven cache-Trash candidates and their approval phrase from one scan. /// This is read-only evidence; arbitrary Trash entries are never included. #[cfg(not(coverage))] #[tauri::command] -pub fn list_proven_cache_trash() -> Result, String> { +pub fn list_proven_cache_trash() -> Result { let bases = rules::BaseDirs::from_env().ok_or("cache-base-directories-unavailable")?; - Ok(crate::cache_cleanup::proven_cache_trash_candidates(&bases.home)) -} - -/// Return the candidate-set-bound phrase required by the desktop confirmation boundary. -#[cfg(not(coverage))] -#[tauri::command] -pub fn proven_cache_trash_approval_phrase() -> Result { - let bases = rules::BaseDirs::from_env().ok_or("cache-base-directories-unavailable")?; - Ok(crate::cache_cleanup::proven_cache_trash_approval_phrase( - &bases.home, - )) + Ok(crate::cache_cleanup::proven_cache_trash_snapshot(&bases.home)) } /// Permanently remove only the reviewed, structurally proven regenerable cache entries in Trash. @@ -735,20 +725,16 @@ pub fn proven_cache_trash_approval_phrase() -> Result { #[tauri::command] pub fn purge_proven_cache_trash( app: AppHandle, - confirmation_phrase: String, + snapshot: crate::cache_cleanup::CacheTrashSnapshot, ) -> Result { let bases = rules::BaseDirs::from_env().ok_or("cache-base-directories-unavailable")?; - if confirmation_phrase - != crate::cache_cleanup::proven_cache_trash_approval_phrase(&bases.home) - { - return Err("cache-trash-confirmation-mismatch".into()); - } let journal_path = journal_file_path(&app)?; let before = crate::volume_pressure::snapshot_volume(&bases.home, now_ms()).ok(); let items = crate::cache_cleanup::purge_proven_cache_trash( &bases.home, &journal_path, now_ms(), + &snapshot, )?; let after = crate::volume_pressure::snapshot_volume(&bases.home, now_ms()).ok(); let before_available_bytes = before.as_ref().map(|snapshot| snapshot.available_bytes); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 55c16f055..2d83272f7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -112,7 +112,6 @@ pub fn run() { commands::list_cache_candidates, commands::clean_regenerable_caches, commands::list_proven_cache_trash, - commands::proven_cache_trash_approval_phrase, commands::purge_proven_cache_trash, cache_cleanup::list_cache_targets, commands::list_dev_artifacts, diff --git a/src/lib/BrewCleanup.svelte b/src/lib/BrewCleanup.svelte index 905a36adb..56fbba4f8 100644 --- a/src/lib/BrewCleanup.svelte +++ b/src/lib/BrewCleanup.svelte @@ -25,8 +25,8 @@ reset(); try { judgment = await api.judgeBrewCleanup(); - } catch (e) { - error = String(e); + } catch { + error = "Homebrew ์ •๋ฆฌ ๋ฒ”์œ„๋ฅผ ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•˜์‹ญ์‹œ์˜ค."; } finally { planning = false; } @@ -35,10 +35,10 @@ function approvalGuidance(): string { if (!judgment || judgment.verdict !== "safe") return ""; if (!judgment.calibration || judgment.calibration.judgment_id !== judgment.judgment_id) { - return "์ด ์ •ํ™•ํ•œ LLM ํŒ์ •์— ์—ฐ๊ฒฐ๋œ fast-mlsirm calibration์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."; + return "์ถ”๊ฐ€ ์•ˆ์ „ ํ™•์ธ์ด ๋๋‚˜์ง€ ์•Š์•„ ์‹คํ–‰ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ •๋ฆฌ ๊ณ„ํš์„ ๋‹ค์‹œ ํ™•์ธํ•˜์‹ญ์‹œ์˜ค."; } if (!judgment.calibration.passed) { - return "fast-mlsirm Judge calibration์ด ํ†ต๊ณผํ•˜์ง€ ์•Š์•„ ์‹คํ–‰ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."; + return "์•ˆ์ „ ํ™•์ธ์„ ํ†ต๊ณผํ•˜์ง€ ์•Š์•„ ์‹คํ–‰ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ •๋ฆฌ ๊ณ„ํš์„ ๋‹ค์‹œ ํ™•์ธํ•˜์‹ญ์‹œ์˜ค."; } if (confirmationPhrase.trim() !== judgment.exact_approval_phrase) { return "์Šน์ธ ๋ฌธ๊ตฌ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."; @@ -64,9 +64,8 @@ async function executeCleanup() { if (!judgment || !executionReady()) return; const okay = await confirm( - "LLM์ด ์•ˆ์ „ํ•˜๋‹ค๊ณ  ํŒ๋‹จํ•œ ๊ณ ์ • ๋ช…๋ น์„ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค.\n\n" - + "brew cleanup --prune-prefix\n\n" - + "Homebrew prefix ์•ˆ์˜ ๋Š์–ด์ง„ ์‹ฌ๋ณผ๋ฆญ ๋งํฌ์™€ ๋นˆ ๋””๋ ‰ํ„ฐ๋ฆฌ๋งŒ ์ •๋ฆฌํ•˜๋ฉฐ, ์‹คํ–‰ ์ „ dry-run ๊ณ„ํš์„ ๋‹ค์‹œ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค.", + "Homebrew์˜ ๋Š์–ด์ง„ ์‹ฌ๋ณผ๋ฆญ ๋งํฌ์™€ ๋นˆ ๋””๋ ‰ํ„ฐ๋ฆฌ๋งŒ ์ •๋ฆฌํ•ฉ๋‹ˆ๋‹ค.\n\n" + + "์‹คํ–‰ ์ „์— ์ •๋ฆฌ ๋ฒ”์œ„๋ฅผ ๋‹ค์‹œ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.", { title: "DiskSage Homebrew ์ •๋ฆฌ", kind: "warning" }, ); if (!okay) return; @@ -81,8 +80,8 @@ confirmationPhrase.trim(), rationale.trim(), ); - } catch (e) { - error = String(e); + } catch { + error = "Homebrew ์ •๋ฆฌ๋ฅผ ์‹คํ–‰ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ์ƒํƒœ๋ฅผ ํ™•์ธํ•œ ๋’ค ๋‹ค์‹œ ์‹œ๋„ํ•˜์‹ญ์‹œ์˜ค."; } finally { judgment = null; confirmationPhrase = ""; @@ -95,10 +94,10 @@
      Homebrew ์ •๋ฆฌ (macOS)

      - ์ฝ๊ธฐ ์ „์šฉ dry-run ๊ฒฐ๊ณผ๋ฅผ ๋กœ์ปฌ LLM์ด ํŒ๋‹จํ•ฉ๋‹ˆ๋‹ค. ์‹คํ–‰ ๋ฒ”์œ„๋Š” Homebrew prefix ์•ˆ์˜ ๋Š์–ด์ง„ ์‹ฌ๋ณผ๋ฆญ ๋งํฌ์™€ ๋นˆ ๋””๋ ‰ํ„ฐ๋ฆฌ๋กœ ์ œํ•œ๋˜๋ฉฐ, Safe์—ฌ๋„ ์‚ฌ๋žŒ์˜ ์Šน์ธ ๋ฌธ๊ตฌ์™€ ์‚ฌ์œ ๋ฅผ ์ž…๋ ฅํ•ด์•ผ ๊ณ ์ • ๋ช…๋ น๋งŒ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค. + Homebrew ์•ˆ์˜ ๋Š์–ด์ง„ ์‹ฌ๋ณผ๋ฆญ ๋งํฌ์™€ ๋นˆ ๋””๋ ‰ํ„ฐ๋ฆฌ๋งŒ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. ์ •๋ฆฌ ๋ฒ”์œ„๋ฅผ ํ™•์ธํ•˜๊ณ  ์Šน์ธ ๋ฌธ๊ตฌ์™€ ์‚ฌ์œ ๋ฅผ ์ž…๋ ฅํ•ด์•ผ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค.

      {#if error}{/if} @@ -106,23 +105,25 @@ {#if judgment || completedJudgment} {@const report = (judgment ?? completedJudgment)!}
      -
      LLM ํŒ์ •: {report.verdict} ยท {report.model_name}
      -

      {report.reason || "๋ชจ๋ธ์ด ์„ค๋ช…์„ ๋ฐ˜ํ™˜ํ•˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."}

      -

      ๊ณ„ํš ์ง€๋ฌธ: {report.plan_fingerprint}

      -

      ์‹คํ–‰ ์˜ˆ์ •: brew cleanup --prune-prefix

      +
      {report.verdict === "safe" ? "์ •๋ฆฌ ๊ฐ€๋Šฅ" : "์ •๋ฆฌ ๋ณด๋ฅ˜"}
      +

      + {report.verdict === "safe" + ? "ํ™•์ธ๋œ Homebrew ์ •๋ฆฌ ๋ฒ”์œ„๋ฅผ ๊ฒ€ํ† ํ•œ ๋’ค ์Šน์ธํ•˜์‹ญ์‹œ์˜ค." + : "์•ˆ์ „ ์กฐ๊ฑด์„ ์ถฉ์กฑํ•˜์ง€ ์•Š์•„ ์ •๋ฆฌํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. Homebrew ์ƒํƒœ๋ฅผ ํ™•์ธํ•œ ๋’ค ๋‹ค์‹œ ์‹œ๋„ํ•˜์‹ญ์‹œ์˜ค."} +

      +

      ์ •๋ฆฌ ๋ฒ”์œ„: Homebrew์˜ ๋Š์–ด์ง„ ์‹ฌ๋ณผ๋ฆญ ๋งํฌ์™€ ๋นˆ ๋””๋ ‰ํ„ฐ๋ฆฌ

      {#if report.calibration}

      - Judge calibration ({report.calibration.engine}): {report.calibration.passed ? "ํ†ต๊ณผ" : "์‹คํŒจ"} - ยท ํ‘œ๋ณธ {report.calibration.sample_count}๊ฐœ ยท ์ผ์น˜์œจ {Math.round(report.calibration.exact_agreement * 100)}% + ์ถ”๊ฐ€ ์•ˆ์ „ ํ™•์ธ: {report.calibration.passed ? "์™„๋ฃŒ" : "๋ฏธ์™„๋ฃŒ"}

      {:else} -

      fast-mlsirm calibration ์ฆ๊ฑฐ๊ฐ€ ์—†์–ด ๋…๋ฆฝ์ ์ธ ์‚ฌ๋žŒ ์Šน์ธ ๋ฌธ๊ตฌ๊ฐ€ ๊ณ„์† ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

      +

      ์ถ”๊ฐ€ ์•ˆ์ „ ํ™•์ธ์ด ์—†์–ด ์‚ฌ๋žŒ์˜ ์Šน์ธ ๋ฌธ๊ตฌ๊ฐ€ ๊ณ„์† ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

      {/if} -
      {report.plan.dry_run_output || "dry-run์—์„œ ์ •๋ฆฌ ๋Œ€์ƒ์ด ๋ณด๊ณ ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."}
      +

      ์ •๋ฆฌ ๋Œ€์ƒ์€ Homebrew ์ƒํƒœ๋ฅผ ๋‹ค์‹œ ํ™•์ธํ•œ ๋’ค์—๋งŒ ์ฒ˜๋ฆฌ๋ฉ๋‹ˆ๋‹ค.

      {#if judgment && judgment.verdict === "safe" && !execution}
      -

      ์•„๋ž˜ ์Šน์ธ ๋ฌธ๊ตฌ ์ „์ฒด๋ฅผ ์ง์ ‘ ์ž…๋ ฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์‹คํ–‰ ์ง์ „์— dry-run ๊ณ„ํš๊ณผ LLM ํŒ๋‹จ์„ ๋‹ค์‹œ ๋Œ€์กฐํ•ฉ๋‹ˆ๋‹ค.

      +

      ์•„๋ž˜ ์Šน์ธ ๋ฌธ๊ตฌ ์ „์ฒด์™€ ์‹คํ–‰ ์‚ฌ์œ ๋ฅผ ์ž…๋ ฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์‹คํ–‰ ์ง์ „์— ์ •๋ฆฌ ๋ฒ”์œ„๋ฅผ ๋‹ค์‹œ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.

      {judgment.exact_approval_phrase} {#if approvalGuidance()}

      {approvalGuidance()}

      {/if}
      {:else if judgment && judgment.verdict !== "safe"} -

      Safe๊ฐ€ ์•„๋‹ˆ๋ฏ€๋กœ ์‹คํ–‰ ๊ถŒํ•œ์„ ๋งŒ๋“ค์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.

      +

      ์•ˆ์ „ ์กฐ๊ฑด์„ ์ถฉ์กฑํ•˜์ง€ ์•Š์•„ ์‹คํ–‰ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๊ณ„ํš์„ ๋‹ค์‹œ ํ™•์ธํ•˜์‹ญ์‹œ์˜ค.

      {/if} {#if execution} -

      - {execution.executed ? `์‹คํ–‰ ์™„๋ฃŒ (์ข…๋ฃŒ ์ฝ”๋“œ ${execution.status_code})` : "์‹คํ–‰๋˜์ง€ ์•Š์Œ"} +

      + {execution.executed && execution.status_code === 0 + ? "Homebrew ์ •๋ฆฌ๋ฅผ ์™„๋ฃŒํ–ˆ์Šต๋‹ˆ๋‹ค." + : "Homebrew ์ •๋ฆฌ๋ฅผ ์™„๋ฃŒํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ์ƒํƒœ๋ฅผ ํ™•์ธํ•œ ๋’ค ๋‹ค์‹œ ์‹œ๋„ํ•˜์‹ญ์‹œ์˜ค."}

      - {#if execution.stdout}
      {execution.stdout}
      {/if} - {#if execution.stderr}
      {execution.stderr}
      {/if} - {#if execution.record_path} -

      ๊ฐ์‚ฌ ๊ธฐ๋ก: {execution.record_path}

      - {:else} - - {/if} {/if}
      {/if} @@ -162,12 +158,10 @@ diff --git a/src/lib/cleanupCustomerCopyContract.test.ts b/src/lib/cleanupCustomerCopyContract.test.ts index 5605328bd..0bfdc8fc0 100644 --- a/src/lib/cleanupCustomerCopyContract.test.ts +++ b/src/lib/cleanupCustomerCopyContract.test.ts @@ -1,10 +1,16 @@ -import { readFileSync } from "node:fs"; +import { readdirSync, 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 visibleText(fileName: string): string { + const source = readFileSync(resolve(repositoryRoot, "src/lib", fileName), "utf8"); + const markup = source.slice(source.indexOf("") + "".length, source.indexOf(" + \ No newline at end of file From 90456276f5c52c06f0e6be100976178bcee7bdee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 14:02:33 +0900 Subject: [PATCH 045/138] test: keep cache copy contract synchronized --- src/lib/Cleanup.svelte | 7 +++++-- src/lib/cacheCleanupReadOnlyUiContract.test.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/lib/Cleanup.svelte b/src/lib/Cleanup.svelte index 13c0ecd76..ae716473b 100644 --- a/src/lib/Cleanup.svelte +++ b/src/lib/Cleanup.svelte @@ -3,7 +3,10 @@ import { fmtBytes } from "./fmt"; import { verdictBadge } from "./verdictBadge"; import { summarizeCacheTrashPurge } from "./cacheTrashPurgeSummary"; - import { cacheTrashPurgeAvailability } from "./cacheTrashPurgeAvailability"; + import { + CACHE_TRASH_NATIVE_REVIEW_MACOS_ONLY, + cacheTrashPurgeAvailability, + } from "./cacheTrashPurgeAvailability"; import { purgeReviewedCacheTrash, reviewProvenCacheTrash } from "./cacheTrashReviewApi"; import { confirm } from "@tauri-apps/plugin-dialog"; import GitWorktreeCleanup from "./GitWorktreeCleanup.svelte"; @@ -255,7 +258,7 @@

      ์žฌ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋Š” ์บ์‹œ๋งŒ ๋Œ€์ƒ์œผ๋กœ ํ•˜๋ฉฐ, ์‚ฌ์šฉ ์ค‘์ด๊ฑฐ๋‚˜ ์ƒํƒœ๊ฐ€ ๋ฐ”๋€ ํ•ญ๋ชฉ์€ ์ž๋™์œผ๋กœ ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค.

      - {#if !cacheTrashSupported && cacheTrashNotice === "cache-trash-native-discovery-macos-only"} + {#if !cacheTrashSupported && cacheTrashNotice === CACHE_TRASH_NATIVE_REVIEW_MACOS_ONLY}

      ํœด์ง€ํ†ต ์† ์žฌ์ƒ์„ฑ ์บ์‹œ ๊ฒ€ํ† ๋Š” ํ˜„์žฌ macOS ๊ธฐ๋ณธ ํœด์ง€ํ†ต์—์„œ๋งŒ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค. ์•ฑ ๋‚ด ์˜๊ตฌ ์‚ญ์ œ๋Š” ์•ˆ์ „ํ•œ ๊ฐ์ฒด ๊ฒฐํ•ฉ ์‚ญ์ œ๋ฅผ ์ œ๊ณตํ•  ๋•Œ๊นŒ์ง€ ๋ชจ๋“  ํ”Œ๋žซํผ์—์„œ ๋น„ํ™œ์„ฑํ™”๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. diff --git a/src/lib/cacheCleanupReadOnlyUiContract.test.ts b/src/lib/cacheCleanupReadOnlyUiContract.test.ts index 3851cb083..473308b58 100644 --- a/src/lib/cacheCleanupReadOnlyUiContract.test.ts +++ b/src/lib/cacheCleanupReadOnlyUiContract.test.ts @@ -23,7 +23,7 @@ describe("cache cleanup fail-closed UX", () => { it("does not render the permanent-delete action when the backend withholds destructive authority", () => { const cleanup = readSource("src/lib/Cleanup.svelte"); - expect(cleanup).toContain('import { cacheTrashPurgeAvailability } from "./cacheTrashPurgeAvailability"'); + expect(cleanup).toContain('cacheTrashPurgeAvailability,'); expect(cleanup).toContain("cacheTrashPurgeInstruction = purgeAvailability.instruction"); expect(cleanup).toContain("cacheTrashApprovalPhrase = purgeAvailability.canPurge ? cacheTrashReview.approval_phrase : null"); expect(cleanup).toContain("{#if cacheTrashPurgeInstruction}"); From cc423cdc83e6b15ebb5f46e61e8957b5cd868717 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:03:30 -0700 Subject: [PATCH 046/138] refactor: remove dead cache-trash support state --- src/lib/Cleanup.svelte | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/lib/Cleanup.svelte b/src/lib/Cleanup.svelte index 92278cb42..42d5797d9 100644 --- a/src/lib/Cleanup.svelte +++ b/src/lib/Cleanup.svelte @@ -15,8 +15,6 @@ let caches: api.CacheCandidate[] = $state([]); let cacheTrash: api.CacheTrashCandidate[] = $state([]); let cacheTrashApprovalPhrase = $state(null); - let cacheTrashSupported = $state(true); - let cacheTrashNotice = $state(null); let cacheTrashPurgeAvailable = $state(false); let cacheTrashPurgeInstruction = $state(null); let cacheTrashExecution: api.CacheTrashPurgeExecution | null = $state(null); @@ -54,8 +52,6 @@ const purgeAvailability = cacheTrashPurgeAvailability(cacheTrashReview); cacheTrash = cacheTrashReview.candidates; cacheTrashApprovalPhrase = purgeAvailability.canPurge ? cacheTrashReview.approval_phrase : null; - cacheTrashSupported = cacheTrashReview.supported; - cacheTrashNotice = cacheTrashReview.notice; cacheTrashPurgeAvailable = purgeAvailability.canPurge; cacheTrashPurgeInstruction = purgeAvailability.instruction; artifacts = scannedRoot ? await api.listDevArtifacts(scannedRoot) : []; @@ -443,4 +439,4 @@ .badge-caution { background: #b8860b; } .badge-keep { background: #b03030; } .badge-unrated { background: #888; } - \ No newline at end of file + From 60085ba46a31ed6d750adc4ec88e9226fcf595dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 14:06:30 +0900 Subject: [PATCH 047/138] chore: format cache trash guard --- src-tauri/src/cache_trash_reclaim.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/cache_trash_reclaim.rs b/src-tauri/src/cache_trash_reclaim.rs index d63c4545c..4b595e58e 100644 --- a/src-tauri/src/cache_trash_reclaim.rs +++ b/src-tauri/src/cache_trash_reclaim.rs @@ -7,7 +7,9 @@ use crate::cache_cleanup::{CacheTrashCandidate, CacheTrashPurgeExecution, CacheT const REVIEW_SCHEMA_KIND: &str = "disksage.cache-trash-review"; const REVIEW_SCHEMA_VERSION: u32 = 1; const MAX_APPROVED_CANDIDATES: usize = 9; -const PERMANENT_DELETE_UNAVAILABLE: &str = "cache-trash-identity-bound-permanent-delete-unavailable"; +const PERMANENT_DELETE_UNAVAILABLE: &str = + "cache-trash-identity-bound-permanent-delete-unavailable"; +#[cfg(not(target_os = "macos"))] const NATIVE_REVIEW_MACOS_ONLY: &str = "cache-trash-native-review-macos-only"; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -240,7 +242,10 @@ pub fn purge_approved_cache_trash( } let immediately_current = crate::cache_cleanup::proven_cache_trash_candidates(home); - let outcome = if immediately_current.iter().any(|observed| observed == &candidate) { + let outcome = if immediately_current + .iter() + .any(|observed| observed == &candidate) + { permanently_remove_identity_bound(&path, expected_object_id, now_ms) } else { Err("cache-trash-approved-candidate-changed".into()) @@ -292,7 +297,8 @@ pub fn purge_proven_cache_trash( ) -> Result { let bases = crate::rules::BaseDirs::from_env().ok_or("cache-base-directories-unavailable")?; let journal_path = crate::commands::journal_file_path(&app)?; - let before = crate::volume_pressure::snapshot_volume(&bases.home, crate::commands::now_ms()).ok(); + let before = + crate::volume_pressure::snapshot_volume(&bases.home, crate::commands::now_ms()).ok(); let items = purge_approved_cache_trash( &bases.home, &approved_candidates, @@ -300,7 +306,8 @@ pub fn purge_proven_cache_trash( &journal_path, crate::commands::now_ms(), )?; - let after = crate::volume_pressure::snapshot_volume(&bases.home, crate::commands::now_ms()).ok(); + let after = + crate::volume_pressure::snapshot_volume(&bases.home, crate::commands::now_ms()).ok(); let before_available_bytes = before.as_ref().map(|snapshot| snapshot.available_bytes); let after_available_bytes = after.as_ref().map(|snapshot| snapshot.available_bytes); let observed_available_gain_bytes = before_available_bytes From d36efa94fb797ff8313dc30941cccab7b6f541ee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:07:58 -0700 Subject: [PATCH 048/138] test: refuse unsafe cache-trash CLI permanent deletion --- .../cache_cleanup_cli_purge_fail_closed.rs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src-tauri/tests/cache_cleanup_cli_purge_fail_closed.rs diff --git a/src-tauri/tests/cache_cleanup_cli_purge_fail_closed.rs b/src-tauri/tests/cache_cleanup_cli_purge_fail_closed.rs new file mode 100644 index 000000000..5a1f60273 --- /dev/null +++ b/src-tauri/tests/cache_cleanup_cli_purge_fail_closed.rs @@ -0,0 +1,34 @@ +use std::fs; +use std::process::Command; + +#[cfg(all(unix, not(target_os = "macos")))] +#[test] +fn shipped_cli_refuses_path_recursive_permanent_cache_trash_deletion() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let trash = home.join(".local/share/Trash/files"); + let npm = trash.join("_cacache"); + fs::create_dir_all(npm.join("content-v2")).unwrap(); + fs::create_dir(npm.join("tmp")).unwrap(); + fs::write(npm.join("content-v2/entry"), b"cache").unwrap(); + let journal = temp.path().join("state/journal.jsonl"); + + let output = Command::new(env!("CARGO_BIN_EXE_disksage-cache-cleanup")) + .env("HOME", &home) + .env_remove("XDG_DATA_HOME") + .args([ + "--execute", + "--purge-proven-cache-trash", + "--journal-path", + ]) + .arg(&journal) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("cache-trash-identity-bound-permanent-delete-unavailable")); + assert!(npm.exists(), "fail-closed CLI must preserve the reviewed cache object"); + assert!(!journal.exists(), "refusal must happen before journal mutation"); +} From 49ced5be44da80e08e70a96d608e35baad317a4b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:08:37 -0700 Subject: [PATCH 049/138] fix: fail closed on cache-trash CLI permanent delete --- src-tauri/src/bin/disksage-cache-cleanup.rs | 32 ++++++--------------- 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/src-tauri/src/bin/disksage-cache-cleanup.rs b/src-tauri/src/bin/disksage-cache-cleanup.rs index 146cb309c..285605273 100644 --- a/src-tauri/src/bin/disksage-cache-cleanup.rs +++ b/src-tauri/src/bin/disksage-cache-cleanup.rs @@ -3,16 +3,16 @@ //! Without `--execute` this command is read-only. With it, the library path moves only inactive, //! identity-bound children of the npm, pnpm, Adobe, Edge, uv, and Trivy cache roots to OS Trash. -use disksage_lib::cache_cleanup::{ - clean_regenerable_caches_headless, proven_cache_trash_snapshot, purge_proven_cache_trash, -}; +use disksage_lib::cache_cleanup::{clean_regenerable_caches_headless, proven_cache_trash_snapshot}; use std::ffi::OsString; use std::path::PathBuf; +const PERMANENT_CACHE_TRASH_DELETE_UNAVAILABLE: &str = + "cache-trash-identity-bound-permanent-delete-unavailable"; const USAGE: &str = "Usage: disksage-cache-cleanup [--execute] [--purge-proven-cache-trash] [--journal-path PATH]\n\ Without --execute it reports the command is a no-op. With --execute it moves only observed,\n\ -inactive regenerable cache children to OS Trash. --purge-proven-cache-trash permanently removes\n\ -only structurally proven cache directories already in OS Trash."; +inactive regenerable cache children to OS Trash. --purge-proven-cache-trash is read-only evidence;\n\ +permanent in-app deletion remains unavailable until the final syscall is object-bound."; #[derive(Debug, PartialEq, Eq)] struct Args { @@ -126,28 +126,12 @@ fn run_with_args(raw_args: impl IntoIterator) -> Result<(), Str ); return Ok(()); } + if args.purge_proven_cache_trash { + return Err(PERMANENT_CACHE_TRASH_DELETE_UNAVAILABLE.into()); + } if let Some(parent) = args.journal_path.parent() { std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; } - if args.purge_proven_cache_trash { - let snapshot = proven_cache_trash_snapshot(&home_directory()?); - let results = purge_proven_cache_trash( - &home_directory()?, - &args.journal_path, - now_ms(), - &snapshot, - )?; - println!( - "{}", - serde_json::json!({ - "executed": true, - "purge_proven_cache_trash": true, - "journal_path": args.journal_path, - "results": results - }) - ); - return Ok(()); - } let evidence = clean_regenerable_caches_headless(&args.journal_path, now_ms())?; println!( "{}", From bd546413fca1db3f2b2380ad24fc22045ba624f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 14:09:26 +0900 Subject: [PATCH 050/138] fix: keep cloud errors customer actionable --- src/lib/cloudArchiveErrorFeedback.ts | 14 +++++++------- src/lib/cloudArchiveErrorPrivacy.test.ts | 10 ++++++++++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/lib/cloudArchiveErrorFeedback.ts b/src/lib/cloudArchiveErrorFeedback.ts index a1b87c639..30f3bcad2 100644 --- a/src/lib/cloudArchiveErrorFeedback.ts +++ b/src/lib/cloudArchiveErrorFeedback.ts @@ -26,18 +26,18 @@ const CLOUD_ARCHIVE_ERROR_MESSAGES: Record = review: "ํด๋ผ์šฐ๋“œ ํ›„๋ณด ๊ฒ€ํ†  ๊ฒฐ์ •์„ ์ €์žฅํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", copy: "ํด๋ผ์šฐ๋“œ ๋ณต์‚ฌ๋ฅผ ์‹คํ–‰ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", cancel: "์ง„ํ–‰ ์ค‘์ธ ํด๋ผ์šฐ๋“œ ๋ณต์‚ฌ๋ฅผ ์ทจ์†Œํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", - "provider-api-copy": "๊ณต๊ธ‰์ž API ์—…๋กœ๋“œ๋ฅผ ์‹คํ–‰ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", - adopt: "๊ธฐ์กด ํด๋ผ์šฐ๋“œ ๋ณต์‚ฌ๋ณธ์„ ๊ฒ€์ฆยท์ฑ„ํƒํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", - attest: "ํด๋ผ์šฐ๋“œ ๋ณต์‚ฌ๋ณธ์˜ ๊ณต๊ธ‰์ž ์ฆ๊ฑฐ๋ฅผ ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", + "provider-api-copy": "์—ฐ๊ฒฐ๋œ ํด๋ผ์šฐ๋“œ ์„œ๋น„์Šค๋กœ ์—…๋กœ๋“œํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ์—ฐ๊ฒฐ ์ƒํƒœ์™€ ์ €์žฅ ๊ณต๊ฐ„์„ ํ™•์ธํ•œ ๋’ค ๋‹ค์‹œ ์‹œ๋„ํ•˜์‹ญ์‹œ์˜ค.", + adopt: "๊ธฐ์กด ํด๋ผ์šฐ๋“œ ๋ณต์‚ฌ๋ณธ์„ ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ํด๋ผ์šฐ๋“œ ์ƒํƒœ๋ฅผ ๋‹ค์‹œ ํ™•์ธํ•œ ๋’ค ๋‹ค์‹œ ์‹œ๋„ํ•˜์‹ญ์‹œ์˜ค.", + attest: "ํด๋ผ์šฐ๋“œ ๋ณต์‚ฌ๋ณธ์˜ ์—…๋กœ๋“œ ์ƒํƒœ๋ฅผ ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ํ™•์ธํ•˜์‹ญ์‹œ์˜ค.", reconcile: "๊ธฐ์กด ํด๋ผ์šฐ๋“œ ์˜์ˆ˜์ฆ์„ ์žฌ๊ฒ€์ฆํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", "icloud-health": "iCloud ๋™๊ธฐํ™” ์ƒํƒœ๋ฅผ ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", "finder-copy-cancel": "Finder ๋ณต์‚ฌ ์ทจ์†Œ ์š”์ฒญ์„ ์™„๋ฃŒํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", - "provider-sync": "๊ณต๊ธ‰์ž ์ „์—ญ ๋™๊ธฐํ™” ์ƒํƒœ๋ฅผ ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", - "provider-recovery": "๊ณต๊ธ‰์ž ์•ฑ ๋ณต๊ตฌ๋ฅผ ์™„๋ฃŒํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", + "provider-sync": "ํด๋ผ์šฐ๋“œ ๋™๊ธฐํ™” ์ƒํƒœ๋ฅผ ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ํ™•์ธํ•˜์‹ญ์‹œ์˜ค.", + "provider-recovery": "ํด๋ผ์šฐ๋“œ ์•ฑ์„ ๋‹ค์‹œ ์ค€๋น„ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ์•ฑ ์ƒํƒœ๋ฅผ ํ™•์ธํ•œ ๋’ค ๋‹ค์‹œ ์‹œ๋„ํ•˜์‹ญ์‹œ์˜ค.", evict: "๊ฒ€์ฆ๋œ ๋กœ์ปฌ ์›๋ณธ์„ ํœด์ง€ํ†ต์œผ๋กœ ์ด๋™ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", capacity: "ํด๋ผ์šฐ๋“œ ๊ณ„์ • ์šฉ๋Ÿ‰์„ ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", - connect: "ํด๋ผ์šฐ๋“œ ๊ณต๊ธ‰์ž OAuth ์—ฐ๊ฒฐ์„ ์™„๋ฃŒํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", - disconnect: "ํด๋ผ์šฐ๋“œ ๊ณต๊ธ‰์ž ์—ฐ๊ฒฐ์„ ํ•ด์ œํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", + connect: "ํด๋ผ์šฐ๋“œ ์—ฐ๊ฒฐ์„ ์™„๋ฃŒํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ์—ฐ๊ฒฐ ์ •๋ณด๋ฅผ ํ™•์ธํ•œ ๋’ค ๋‹ค์‹œ ์‹œ๋„ํ•˜์‹ญ์‹œ์˜ค.", + disconnect: "ํด๋ผ์šฐ๋“œ ์—ฐ๊ฒฐ์„ ํ•ด์ œํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ์—ฐ๊ฒฐ ์ƒํƒœ๋ฅผ ํ™•์ธํ•œ ๋’ค ๋‹ค์‹œ ์‹œ๋„ํ•˜์‹ญ์‹œ์˜ค.", }; function caughtErrorMessage(caughtError: unknown): string | null { diff --git a/src/lib/cloudArchiveErrorPrivacy.test.ts b/src/lib/cloudArchiveErrorPrivacy.test.ts index 236632ad6..07f60995e 100644 --- a/src/lib/cloudArchiveErrorPrivacy.test.ts +++ b/src/lib/cloudArchiveErrorPrivacy.test.ts @@ -114,6 +114,16 @@ describe("CloudArchive bounded error feedback", () => { expect(messages.size).toBe(operations.length); }); + it("keeps implementation boundaries out of shared customer messages", () => { + const forbidden = ["๊ณต๊ธ‰์ž API", "๊ณต๊ธ‰์ž ์ฆ๊ฑฐ", "๊ณต๊ธ‰์ž ์ „์—ญ", "๊ณต๊ธ‰์ž ์•ฑ", "OAuth"]; + + for (const operation of operations) { + const message = boundedCloudArchiveErrorMessage(operation, "backend detail"); + for (const term of forbidden) expect(message).not.toContain(term); + expect(message).toMatch(/(ํ™•์ธ|๋‹ค์‹œ|์‹œ๋„|์—ฐ๊ฒฐ|์™„๋ฃŒ|๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค)/); + } + }); + it("routes every CloudArchive catch boundary through bounded feedback", () => { const source = readFileSync(resolve(repositoryRoot, "src/lib/CloudArchive.svelte"), "utf8"); From a2c0f4717f5d67f643e96eecc0b9da611c295632 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:10:00 -0700 Subject: [PATCH 051/138] fix: remove pathname-recursive cache-trash purge authority --- src-tauri/src/cache_cleanup.rs | 162 +++++---------------------------- 1 file changed, 24 insertions(+), 138 deletions(-) diff --git a/src-tauri/src/cache_cleanup.rs b/src-tauri/src/cache_cleanup.rs index 2260d8617..b5032b325 100644 --- a/src-tauri/src/cache_cleanup.rs +++ b/src-tauri/src/cache_cleanup.rs @@ -31,6 +31,8 @@ const PROVEN_CACHE_TRASH_NAMES: [&str; 9] = [ "db", ]; const MAX_CACHE_TRASH_ENTRIES: usize = 1_000_000; +const PERMANENT_CACHE_TRASH_DELETE_UNAVAILABLE: &str = + "cache-trash-identity-bound-permanent-delete-unavailable"; /// A cache directory already in OS Trash whose structure is still recognizable without reading /// user file contents. Permanent removal is intentionally limited to these signatures. @@ -45,9 +47,8 @@ pub struct CacheTrashCandidate { /// Candidate list and approval token produced by one Trash scan. /// -/// The desktop must submit this exact snapshot for permanent removal. The backend still -/// revalidates every item immediately before deleting it, but never expands the approved set by -/// scanning for new entries during the destructive operation. +/// The snapshot is read-only evidence. Permanent deletion remains unavailable until DiskSage can +/// bind the final irreversible syscall to the exact reviewed filesystem object. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CacheTrashSnapshot { @@ -253,99 +254,26 @@ pub fn proven_cache_trash_snapshot(home: &Path) -> CacheTrashSnapshot { } } -/// Return a candidate-set-bound approval phrase for the desktop confirmation boundary. +/// Return a candidate-set-bound approval phrase for read-only review evidence. /// The phrase is opaque to the customer and changes whenever the proven Trash set changes. pub fn proven_cache_trash_approval_phrase(home: &Path) -> String { proven_cache_trash_snapshot(home).approval_phrase } -/// Permanently remove only the proven cache directories in OS Trash. The explicit CLI flag is the -/// approval boundary; each object is rechecked immediately before removal and journaled. +/// Refuse permanent cache-Trash deletion until the final irreversible syscall can be bound to the +/// exact reviewed filesystem object. Candidate-only path/signature/size revalidation is not enough +/// to authorize `remove_dir_all`, because a same-user replacement can occur after the pathname +/// checks and before recursive deletion. pub fn purge_proven_cache_trash( - home: &Path, - journal_path: &Path, - now_ms: u64, + _home: &Path, + _journal_path: &Path, + _now_ms: u64, snapshot: &CacheTrashSnapshot, ) -> Result, String> { if snapshot.approval_phrase != approval_phrase_for_candidates(&snapshot.candidates) { return Err("cache-trash-confirmation-mismatch".into()); } - let Some(trash) = trash_directory(home) else { - return Ok(Vec::new()); - }; - let planned = snapshot.candidates.clone(); - let mut results = Vec::with_capacity(planned.len()); - for candidate in planned { - let path = PathBuf::from(&candidate.path); - let path_is_direct_trash_child = path.parent() == Some(trash.as_path()) - && path - .file_name() - .is_some_and(|name| name.to_string_lossy() == candidate.name) - && !path - .components() - .any(|component| matches!(component, std::path::Component::ParentDir)); - let mut entry = crate::safety::JournalEntry { - ts_ms: now_ms, - op: "permanent_cache_trash_delete".into(), - path: candidate.path.clone(), - bytes: candidate.bytes, - outcome: "pending".into(), - }; - if let Err(error) = crate::safety::journal_append(journal_path, &entry) { - results.push(CacheTrashPurgeResult { - name: candidate.name, - path: candidate.path, - bytes: candidate.bytes, - signature: candidate.signature, - purged: false, - error: format!("journal-write-failed:{error}"), - }); - continue; - } - let mut count = 0; - let unchanged_size = - bounded_tree_size(&path, &mut count).is_ok_and(|bytes| bytes == candidate.bytes); - let outcome = if path_is_direct_trash_child - && unchanged_size - && looks_like_proven_cache_trash(&path, &candidate.name) - .is_some_and(|signature| signature == candidate.signature) - { - match std::fs::remove_dir_all(&path) { - Ok(()) => Ok(()), - Err(error) => Err(error.to_string()), - } - } else { - Err("cache-trash-signature-changed".into()) - }; - entry.outcome = match &outcome { - Ok(()) => "ok".into(), - Err(error) => format!("error:{error}"), - }; - let journal_error = crate::safety::journal_append(journal_path, &entry) - .err() - .map(|error| error.to_string()); - let operation_error = outcome.as_ref().err().cloned(); - results.push(CacheTrashPurgeResult { - name: candidate.name, - path: candidate.path, - bytes: candidate.bytes, - signature: candidate.signature, - purged: outcome.is_ok(), - error: merge_purge_errors(operation_error, journal_error), - }); - } - Ok(results) -} - -fn merge_purge_errors(operation_error: Option, journal_error: Option) -> String { - match (operation_error, journal_error) { - (None, None) => String::new(), - (Some(operation), None) => operation, - (None, Some(journal)) => format!("purged-but-journal-write-failed:{journal}"), - (Some(operation), Some(journal)) => { - format!("{operation};journal-write-failed:{journal}") - } - } + Err(PERMANENT_CACHE_TRASH_DELETE_UNAVAILABLE.into()) } fn active_use_blocker( @@ -574,7 +502,7 @@ mod tests { #[cfg(not(windows))] #[test] - fn proven_cache_trash_requires_signature_and_journals_purge() { + fn proven_cache_trash_requires_signature_but_permanent_delete_is_unavailable() { let tmp = tempfile::tempdir().unwrap(); let trash = trash_directory(tmp.path()).unwrap(); fs::create_dir(&trash).unwrap(); @@ -595,58 +523,16 @@ mod tests { let journal = tmp.path().join("journal.jsonl"); let snapshot = proven_cache_trash_snapshot(tmp.path()); - let results = purge_proven_cache_trash(tmp.path(), &journal, 7, &snapshot).unwrap(); - assert_eq!(results.len(), 1); - assert!(results[0].purged); - assert!(!npm.exists()); - let journal_text = fs::read_to_string(journal).unwrap(); - assert!(journal_text.contains("permanent_cache_trash_delete")); - assert!(journal_text.contains("\"outcome\":\"ok\"")); - assert_ne!( - approval_phrase, - proven_cache_trash_approval_phrase(tmp.path()) - ); - } - - #[test] - fn purge_error_keeps_terminal_journal_failure_visible() { - assert_eq!(merge_purge_errors(None, None), ""); - assert_eq!( - merge_purge_errors(None, Some("disk-full".into())), - "purged-but-journal-write-failed:disk-full" - ); - assert_eq!( - merge_purge_errors(Some("remove-failed".into()), Some("disk-full".into())), - "remove-failed;journal-write-failed:disk-full" - ); - } - - #[cfg(not(windows))] - #[test] - fn pending_journal_failure_is_returned_without_deleting() { - let tmp = tempfile::tempdir().unwrap(); - let trash = trash_directory(tmp.path()).unwrap(); - fs::create_dir(&trash).unwrap(); - let npm = trash.join("_cacache"); - fs::create_dir_all(npm.join("content-v2")).unwrap(); - fs::create_dir(npm.join("tmp")).unwrap(); - fs::write(npm.join("content-v2").join("entry"), b"cache").unwrap(); - let journal_directory = tmp.path().join("journal-directory"); - fs::create_dir(&journal_directory).unwrap(); - - let snapshot = proven_cache_trash_snapshot(tmp.path()); - let results = - purge_proven_cache_trash(tmp.path(), &journal_directory, 7, &snapshot).unwrap(); - - assert_eq!(results.len(), 1); - assert!(!results[0].purged); - assert!(results[0].error.starts_with("journal-write-failed:")); + let error = purge_proven_cache_trash(tmp.path(), &journal, 7, &snapshot).unwrap_err(); + assert_eq!(error, PERMANENT_CACHE_TRASH_DELETE_UNAVAILABLE); assert!(npm.exists()); + assert!(!journal.exists()); + assert_eq!(approval_phrase, proven_cache_trash_approval_phrase(tmp.path())); } #[cfg(not(windows))] #[test] - fn purge_never_expands_beyond_submitted_snapshot() { + fn fail_closed_purge_never_expands_or_mutates_submitted_snapshot() { let tmp = tempfile::tempdir().unwrap(); let trash = trash_directory(tmp.path()).unwrap(); fs::create_dir_all(&trash).unwrap(); @@ -662,11 +548,11 @@ mod tests { fs::create_dir(pnpm.join("metadata-full")).unwrap(); let journal = tmp.path().join("journal.jsonl"); - let results = purge_proven_cache_trash(tmp.path(), &journal, 7, &snapshot).unwrap(); - assert_eq!(results.len(), 1); - assert!(results[0].purged); - assert!(!npm.exists()); - assert!(pnpm.exists(), "entries added after approval must remain"); + let error = purge_proven_cache_trash(tmp.path(), &journal, 7, &snapshot).unwrap_err(); + assert_eq!(error, PERMANENT_CACHE_TRASH_DELETE_UNAVAILABLE); + assert!(npm.exists(), "reviewed cache must remain without object-bound delete"); + assert!(pnpm.exists(), "entries added after review must remain"); + assert!(!journal.exists()); } #[cfg(not(windows))] From 1e7b5947330d9717eb7633b2ef563d0af548ab88 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:10:13 -0700 Subject: [PATCH 052/138] test: distinguish cache purge operation and audit failures --- src/lib/cacheTrashPurgeItemMessage.test.ts | 30 ++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/lib/cacheTrashPurgeItemMessage.test.ts diff --git a/src/lib/cacheTrashPurgeItemMessage.test.ts b/src/lib/cacheTrashPurgeItemMessage.test.ts new file mode 100644 index 000000000..c582e0334 --- /dev/null +++ b/src/lib/cacheTrashPurgeItemMessage.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { cacheTrashPurgeItemMessage } from "./cacheTrashPurgeItemMessage"; + +const base = { + name: "_cacache", + path: "/Users/example/.Trash/_cacache", + bytes: 10, + signature: "npm-cacache", +}; + +describe("cacheTrashPurgeItemMessage", () => { + it("distinguishes deletion failure from post-delete journal failure", () => { + expect( + cacheTrashPurgeItemMessage({ + ...base, + purged: false, + error: "cache-trash-identity-bound-permanent-delete-unavailable", + }), + ).toBe("_cacache: ์˜๊ตฌ ์‚ญ์ œํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ๋ชฉ๋ก์„ ํ™•์ธํ•œ ๋’ค ๋‹ค์‹œ ์‹œ๋„ํ•˜์‹ญ์‹œ์˜ค."); + + expect( + cacheTrashPurgeItemMessage({ + ...base, + purged: true, + error: "purged-but-journal-write-failed:disk-full", + }), + ).toBe("_cacache: ์˜๊ตฌ ์‚ญ์ œ๋Š” ์™„๋ฃŒํ–ˆ์ง€๋งŒ ์ •๋ฆฌ ๊ธฐ๋ก์„ ๋‚จ๊ธฐ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ๊ธฐ๋ก ์ƒํƒœ๋ฅผ ํ™•์ธํ•˜์‹ญ์‹œ์˜ค."); + }); +}); From c0a9cce5586ba01f0c8caf9db0fd4c9f9851c0a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 14:10:24 +0900 Subject: [PATCH 053/138] chore: format cache cleanup cli --- src-tauri/src/bin/disksage-cache-cleanup.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/bin/disksage-cache-cleanup.rs b/src-tauri/src/bin/disksage-cache-cleanup.rs index 285605273..e44d013fc 100644 --- a/src-tauri/src/bin/disksage-cache-cleanup.rs +++ b/src-tauri/src/bin/disksage-cache-cleanup.rs @@ -162,11 +162,8 @@ mod tests { #[test] fn help_must_be_used_alone() { - let error = parse_args([ - OsString::from("--help"), - OsString::from("--execute"), - ]) - .unwrap_err(); + let error = + parse_args([OsString::from("--help"), OsString::from("--execute")]).unwrap_err(); assert!(error.starts_with("--help must be used alone")); } From b2397f0622b1ea799063ad56e39a21b863324078 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:10:24 -0700 Subject: [PATCH 054/138] fix: distinguish cache purge operation and audit failures --- src/lib/cacheTrashPurgeItemMessage.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/lib/cacheTrashPurgeItemMessage.ts diff --git a/src/lib/cacheTrashPurgeItemMessage.ts b/src/lib/cacheTrashPurgeItemMessage.ts new file mode 100644 index 000000000..0d8a57ecb --- /dev/null +++ b/src/lib/cacheTrashPurgeItemMessage.ts @@ -0,0 +1,9 @@ +import type { CacheTrashPurgeResult } from "./api"; + +/** Return a bounded operator message without exposing backend error details. */ +export function cacheTrashPurgeItemMessage(item: CacheTrashPurgeResult): string { + if (item.purged) { + return `${item.name}: ์˜๊ตฌ ์‚ญ์ œ๋Š” ์™„๋ฃŒํ–ˆ์ง€๋งŒ ์ •๋ฆฌ ๊ธฐ๋ก์„ ๋‚จ๊ธฐ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ๊ธฐ๋ก ์ƒํƒœ๋ฅผ ํ™•์ธํ•˜์‹ญ์‹œ์˜ค.`; + } + return `${item.name}: ์˜๊ตฌ ์‚ญ์ œํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ๋ชฉ๋ก์„ ํ™•์ธํ•œ ๋’ค ๋‹ค์‹œ ์‹œ๋„ํ•˜์‹ญ์‹œ์˜ค.`; +} From a219d0997f8efda467a7e1c4cef6c13408b1968a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:11:47 -0700 Subject: [PATCH 055/138] fix: distinguish cache purge and journal failures --- src/lib/Cleanup.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/Cleanup.svelte b/src/lib/Cleanup.svelte index 42d5797d9..c449d19de 100644 --- a/src/lib/Cleanup.svelte +++ b/src/lib/Cleanup.svelte @@ -3,6 +3,7 @@ import { fmtBytes } from "./fmt"; import { verdictBadge } from "./verdictBadge"; import { summarizeCacheTrashPurge } from "./cacheTrashPurgeSummary"; + import { cacheTrashPurgeItemMessage } from "./cacheTrashPurgeItemMessage"; import { cacheTrashPurgeAvailability } from "./cacheTrashPurgeAvailability"; import { purgeReviewedCacheTrash, reviewProvenCacheTrash } from "./cacheTrashReviewApi"; import { confirm } from "@tauri-apps/plugin-dialog"; @@ -282,7 +283,7 @@ {#if purgeSummary.errors.length > 0}

        {#each cacheTrashExecution.items.filter((item) => item.error.length > 0) as item (item.path)} -
      • {item.name}: ์ •๋ฆฌ ๊ธฐ๋ก์„ ๋‚จ๊ธฐ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ๋ชฉ๋ก์„ ํ™•์ธํ•œ ๋’ค ๋‹ค์‹œ ์‹œ๋„ํ•˜์‹ญ์‹œ์˜ค.
      • +
      • {cacheTrashPurgeItemMessage(item)}
      • {/each}
      {/if} From cbb6ded7454d1efa358c2c0b6175d34beabe576f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 14:14:48 +0900 Subject: [PATCH 056/138] test: enforce bounded cache purge feedback --- src/lib/Cleanup.svelte | 3 ++- src/lib/cacheCleanupFlowContract.test.ts | 2 +- src/lib/cacheCleanupReadOnlyUiContract.test.ts | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/Cleanup.svelte b/src/lib/Cleanup.svelte index 42d5797d9..c449d19de 100644 --- a/src/lib/Cleanup.svelte +++ b/src/lib/Cleanup.svelte @@ -3,6 +3,7 @@ import { fmtBytes } from "./fmt"; import { verdictBadge } from "./verdictBadge"; import { summarizeCacheTrashPurge } from "./cacheTrashPurgeSummary"; + import { cacheTrashPurgeItemMessage } from "./cacheTrashPurgeItemMessage"; import { cacheTrashPurgeAvailability } from "./cacheTrashPurgeAvailability"; import { purgeReviewedCacheTrash, reviewProvenCacheTrash } from "./cacheTrashReviewApi"; import { confirm } from "@tauri-apps/plugin-dialog"; @@ -282,7 +283,7 @@ {#if purgeSummary.errors.length > 0}
        {#each cacheTrashExecution.items.filter((item) => item.error.length > 0) as item (item.path)} -
      • {item.name}: ์ •๋ฆฌ ๊ธฐ๋ก์„ ๋‚จ๊ธฐ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ๋ชฉ๋ก์„ ํ™•์ธํ•œ ๋’ค ๋‹ค์‹œ ์‹œ๋„ํ•˜์‹ญ์‹œ์˜ค.
      • +
      • {cacheTrashPurgeItemMessage(item)}
      • {/each}
      {/if} diff --git a/src/lib/cacheCleanupFlowContract.test.ts b/src/lib/cacheCleanupFlowContract.test.ts index 5cbe63f1a..8e41bba78 100644 --- a/src/lib/cacheCleanupFlowContract.test.ts +++ b/src/lib/cacheCleanupFlowContract.test.ts @@ -21,7 +21,7 @@ describe("cache cleanup execution boundary", () => { expect(cleanup).toContain("reviewProvenCacheTrash()"); expect(cleanup).toContain("purgeReviewedCacheTrash(reviewedCandidates, approvalPhrase)"); expect(cleanup).toContain("summarizeCacheTrashPurge(cacheTrashExecution.items)"); - expect(cleanup).toContain("์ •๋ฆฌ ๊ธฐ๋ก์„ ๋‚จ๊ธฐ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค"); + expect(cleanup).toContain("cacheTrashPurgeItemMessage(item)"); expect(cleanup).toContain("cache-trash-confirmation-mismatch"); expect(cleanup).toContain("ํœด์ง€ํ†ต ๋‚ด์šฉ์ด ๋ฐ”๋€Œ์–ด ์ตœ์‹  ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์™”์Šต๋‹ˆ๋‹ค"); expect(cleanup).toContain("observed_available_gain_bytes"); diff --git a/src/lib/cacheCleanupReadOnlyUiContract.test.ts b/src/lib/cacheCleanupReadOnlyUiContract.test.ts index 7ef4f134b..b7c912971 100644 --- a/src/lib/cacheCleanupReadOnlyUiContract.test.ts +++ b/src/lib/cacheCleanupReadOnlyUiContract.test.ts @@ -26,6 +26,8 @@ describe("cache cleanup fail-closed UX", () => { expect(cleanup).toContain('cacheTrashPurgeAvailability } from "./cacheTrashPurgeAvailability"'); expect(cleanup).toContain("cacheTrashPurgeInstruction = purgeAvailability.instruction"); expect(cleanup).toContain("cacheTrashApprovalPhrase = purgeAvailability.canPurge ? cacheTrashReview.approval_phrase : null"); + expect(cleanup).toContain('import { cacheTrashPurgeItemMessage } from "./cacheTrashPurgeItemMessage"'); + expect(cleanup).toContain("cacheTrashPurgeItemMessage(item)"); expect(cleanup).toContain("{#if cacheTrashPurgeInstruction}"); expect(cleanup).toContain("{:else if cacheTrash.length > 0}"); }); From e5f205535958e5974772df393416eeadc5ca182a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:15:35 -0700 Subject: [PATCH 057/138] test: distinguish production date confidence labels --- src/lib/productionTimeConfidenceLabel.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/lib/productionTimeConfidenceLabel.test.ts diff --git a/src/lib/productionTimeConfidenceLabel.test.ts b/src/lib/productionTimeConfidenceLabel.test.ts new file mode 100644 index 000000000..2bcc3921e --- /dev/null +++ b/src/lib/productionTimeConfidenceLabel.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; + +import { productionTimeConfidenceLabel } from "./productionTimeConfidenceLabel"; + +describe("productionTimeConfidenceLabel", () => { + it("does not present estimated production dates as confirmed", () => { + expect(productionTimeConfidenceLabel("high")).toBe("์ƒ์‚ฐ์ผ ํ™•์ธ๋จ"); + expect(productionTimeConfidenceLabel("medium")).toBe("์ƒ์‚ฐ์ผ ์ถ”์ •ยท์ค‘๊ฐ„ ํ™•์‹ "); + expect(productionTimeConfidenceLabel("low")).toBe("์ƒ์‚ฐ์ผ ์ถ”์ •ยท๋‚ฎ์€ ํ™•์‹ "); + expect(productionTimeConfidenceLabel("unknown")).toBe("์ƒ์‚ฐ์ผ ๋ฏธํ™•์ธ"); + }); +}); From 57f8bf273c3f98ac4ae6d682c8383a89cdba957a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:15:51 -0700 Subject: [PATCH 058/138] fix: label production date confidence accurately --- src/lib/productionTimeConfidenceLabel.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/lib/productionTimeConfidenceLabel.ts diff --git a/src/lib/productionTimeConfidenceLabel.ts b/src/lib/productionTimeConfidenceLabel.ts new file mode 100644 index 000000000..82b5cb7df --- /dev/null +++ b/src/lib/productionTimeConfidenceLabel.ts @@ -0,0 +1,11 @@ +type ProductionTimeConfidence = "high" | "medium" | "low" | "unknown"; + +/** Render production-date confidence without overstating estimated metadata as confirmed. */ +export function productionTimeConfidenceLabel(confidence: ProductionTimeConfidence): string { + return { + high: "์ƒ์‚ฐ์ผ ํ™•์ธ๋จ", + medium: "์ƒ์‚ฐ์ผ ์ถ”์ •ยท์ค‘๊ฐ„ ํ™•์‹ ", + low: "์ƒ์‚ฐ์ผ ์ถ”์ •ยท๋‚ฎ์€ ํ™•์‹ ", + unknown: "์ƒ์‚ฐ์ผ ๋ฏธํ™•์ธ", + }[confidence]; +} From 76a0005856cd8c7cc16939e932ea8cc0ecdd49e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 14:15:59 +0900 Subject: [PATCH 059/138] test: gate platform-specific cache cleanup imports --- .../cache_cleanup_cli_purge_fail_closed.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src-tauri/tests/cache_cleanup_cli_purge_fail_closed.rs b/src-tauri/tests/cache_cleanup_cli_purge_fail_closed.rs index 5a1f60273..ef759dd54 100644 --- a/src-tauri/tests/cache_cleanup_cli_purge_fail_closed.rs +++ b/src-tauri/tests/cache_cleanup_cli_purge_fail_closed.rs @@ -1,4 +1,6 @@ +#[cfg(all(unix, not(target_os = "macos")))] use std::fs; +#[cfg(all(unix, not(target_os = "macos")))] use std::process::Command; #[cfg(all(unix, not(target_os = "macos")))] @@ -16,11 +18,7 @@ fn shipped_cli_refuses_path_recursive_permanent_cache_trash_deletion() { let output = Command::new(env!("CARGO_BIN_EXE_disksage-cache-cleanup")) .env("HOME", &home) .env_remove("XDG_DATA_HOME") - .args([ - "--execute", - "--purge-proven-cache-trash", - "--journal-path", - ]) + .args(["--execute", "--purge-proven-cache-trash", "--journal-path"]) .arg(&journal) .output() .unwrap(); @@ -29,6 +27,12 @@ fn shipped_cli_refuses_path_recursive_permanent_cache_trash_deletion() { assert!(output.stdout.is_empty()); let stderr = String::from_utf8(output.stderr).unwrap(); assert!(stderr.contains("cache-trash-identity-bound-permanent-delete-unavailable")); - assert!(npm.exists(), "fail-closed CLI must preserve the reviewed cache object"); - assert!(!journal.exists(), "refusal must happen before journal mutation"); + assert!( + npm.exists(), + "fail-closed CLI must preserve the reviewed cache object" + ); + assert!( + !journal.exists(), + "refusal must happen before journal mutation" + ); } From c6ddd73c92a9a631fc538aedeb385e96b2836fca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:16:26 -0700 Subject: [PATCH 060/138] test: bind purge error copy to its helper --- src/lib/cacheCleanupFlowContract.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/cacheCleanupFlowContract.test.ts b/src/lib/cacheCleanupFlowContract.test.ts index 5cbe63f1a..22198a629 100644 --- a/src/lib/cacheCleanupFlowContract.test.ts +++ b/src/lib/cacheCleanupFlowContract.test.ts @@ -12,6 +12,7 @@ function readSource(path: string): string { describe("cache cleanup execution boundary", () => { it("keeps cache mutation behind one fail-closed backend authority", () => { const cleanup = readSource("src/lib/Cleanup.svelte"); + const purgeItemMessage = readSource("src/lib/cacheTrashPurgeItemMessage.ts"); const backend = readSource("src-tauri/src/cache_cleanup.rs"); const tauri = readSource("src-tauri/src/lib.rs"); @@ -21,7 +22,9 @@ describe("cache cleanup execution boundary", () => { expect(cleanup).toContain("reviewProvenCacheTrash()"); expect(cleanup).toContain("purgeReviewedCacheTrash(reviewedCandidates, approvalPhrase)"); expect(cleanup).toContain("summarizeCacheTrashPurge(cacheTrashExecution.items)"); - expect(cleanup).toContain("์ •๋ฆฌ ๊ธฐ๋ก์„ ๋‚จ๊ธฐ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค"); + expect(cleanup).toContain("cacheTrashPurgeItemMessage(item)"); + expect(purgeItemMessage).toContain("์˜๊ตฌ ์‚ญ์ œ๋Š” ์™„๋ฃŒํ–ˆ์ง€๋งŒ ์ •๋ฆฌ ๊ธฐ๋ก์„ ๋‚จ๊ธฐ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค"); + expect(purgeItemMessage).toContain("์˜๊ตฌ ์‚ญ์ œํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค"); expect(cleanup).toContain("cache-trash-confirmation-mismatch"); expect(cleanup).toContain("ํœด์ง€ํ†ต ๋‚ด์šฉ์ด ๋ฐ”๋€Œ์–ด ์ตœ์‹  ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์™”์Šต๋‹ˆ๋‹ค"); expect(cleanup).toContain("observed_available_gain_bytes"); From 8189f17b6e0e82e9f5b2e2f27fed8f1e5b4c2c67 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 14:17:39 +0900 Subject: [PATCH 061/138] fix: render production date confidence accurately --- src/lib/CloudArchive.svelte | 3 ++- src/lib/productionTimeConfidenceLabel.test.ts | 4 ++++ src/lib/productionTimeConfidenceLabel.ts | 20 ++++++++++--------- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/lib/CloudArchive.svelte b/src/lib/CloudArchive.svelte index 48b546704..0dcacef0f 100644 --- a/src/lib/CloudArchive.svelte +++ b/src/lib/CloudArchive.svelte @@ -19,6 +19,7 @@ isCloudCopyCancelled, } from "./cloudArchiveErrorFeedback"; import { fmtBytes } from "./fmt"; + import { productionTimeConfidenceLabel } from "./productionTimeConfidenceLabel"; import IcloudLocalEviction from "./IcloudLocalEviction.svelte"; const RECONCILIATION_INTERVAL_MS = 60_000; @@ -1492,7 +1493,7 @@ {fmtBytes(candidate.bytes)} {candidate.kind} ์ƒ์‚ฐ {productionDate(candidate.production_time_ms)} - ์ƒ์‚ฐ์ผ ํ™•์ธ๋จ + {productionTimeConfidenceLabel(candidate.production_time_confidence)} ์ˆ˜์ • ํ›„ {candidate.age_days.toLocaleString()}์ผ {#if candidate.requires_review}๋งฅ๋ฝ/๋ฏผ๊ฐ์ •๋ณด ๊ฒ€ํ†  ํ•„์š”{/if} {#if candidate.blocked_reason}{customerDecisionReasonLabel(candidate.blocked_reason)}{/if} diff --git a/src/lib/productionTimeConfidenceLabel.test.ts b/src/lib/productionTimeConfidenceLabel.test.ts index 2bcc3921e..f6724006f 100644 --- a/src/lib/productionTimeConfidenceLabel.test.ts +++ b/src/lib/productionTimeConfidenceLabel.test.ts @@ -9,4 +9,8 @@ describe("productionTimeConfidenceLabel", () => { expect(productionTimeConfidenceLabel("low")).toBe("์ƒ์‚ฐ์ผ ์ถ”์ •ยท๋‚ฎ์€ ํ™•์‹ "); expect(productionTimeConfidenceLabel("unknown")).toBe("์ƒ์‚ฐ์ผ ๋ฏธํ™•์ธ"); }); + + it("fails closed for an unrecognized backend value", () => { + expect(productionTimeConfidenceLabel("filename:path-token")).toBe("์ƒ์‚ฐ์ผ ๋ฏธํ™•์ธ"); + }); }); diff --git a/src/lib/productionTimeConfidenceLabel.ts b/src/lib/productionTimeConfidenceLabel.ts index 82b5cb7df..5e6710ab5 100644 --- a/src/lib/productionTimeConfidenceLabel.ts +++ b/src/lib/productionTimeConfidenceLabel.ts @@ -1,11 +1,13 @@ -type ProductionTimeConfidence = "high" | "medium" | "low" | "unknown"; - /** Render production-date confidence without overstating estimated metadata as confirmed. */ -export function productionTimeConfidenceLabel(confidence: ProductionTimeConfidence): string { - return { - high: "์ƒ์‚ฐ์ผ ํ™•์ธ๋จ", - medium: "์ƒ์‚ฐ์ผ ์ถ”์ •ยท์ค‘๊ฐ„ ํ™•์‹ ", - low: "์ƒ์‚ฐ์ผ ์ถ”์ •ยท๋‚ฎ์€ ํ™•์‹ ", - unknown: "์ƒ์‚ฐ์ผ ๋ฏธํ™•์ธ", - }[confidence]; +export function productionTimeConfidenceLabel(confidence: string | null | undefined): string { + switch (confidence) { + case "high": + return "์ƒ์‚ฐ์ผ ํ™•์ธ๋จ"; + case "medium": + return "์ƒ์‚ฐ์ผ ์ถ”์ •ยท์ค‘๊ฐ„ ํ™•์‹ "; + case "low": + return "์ƒ์‚ฐ์ผ ์ถ”์ •ยท๋‚ฎ์€ ํ™•์‹ "; + default: + return "์ƒ์‚ฐ์ผ ๋ฏธํ™•์ธ"; + } } From 5f7a7e19995f28ebe49c741e4f671639728bb91a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 14:21:04 +0900 Subject: [PATCH 062/138] docs: align cache reclaim baseline with fail-closed behavior --- docs/product-technical-gap-baseline.md | 32 ++++++++++++++------------ 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 3db07d220..ba69403c2 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -167,27 +167,29 @@ At each scheduled or operator loop, update this file only with new dated evidenc ## 2026-08-26 physical reclaim loop -- PR #263 current implementation head (`9a2a7fc7a3b5a46763f9eafcb0c658edc93bfa53`, base - `2a727dab04cd6bd73c68d75c54c3e1563bcb16ca`) - closes the physical-reclaim gap after cache cleanup: the desktop cleanup screen lists only - regenerable cache entries already in OS Trash, requires a native confirmation, permanently - removes only those revalidated entries, and reports the observed before/after available-space - change when the filesystem probe succeeds. User files, cloud placeholders, and unrelated Trash - entries remain outside the action; each item remains journaled. +- PR #263 review snapshot at implementation head `a05a443e8efecf9972dcd93b422f488ef40a54a9` + (base `2a727dab04cd6bd73c68d75c54c3e1563bcb16ca`) closes the unsafe-authority gap after cache + cleanup: the desktop cleanup screen lists only regenerable cache entries already in native + macOS Trash, binds review to one candidate snapshot, and reports before/after available-space + observations without claiming that concurrent changes were caused by DiskSage. The final + irreversible deletion primitive is not object-bound yet, so both the Tauri action and the + headless CLI fail closed; the screen directs the operator to empty Trash manually. User files, + cloud placeholders, and unrelated Trash entries remain outside the action; each attempted item + remains journaled. - The exact-head PR is open with auto-merge enabled, but hosted checks are still queued or running and no qualifying independent approval is recorded. This is not merge evidence; protected merge remains blocked until the current head has green required checks and a fresh approval. - A terminal journal-write failure after removal now remains attached to that item's result, so an irreversible deletion is never hidden behind a generic all-or-nothing error; the audit gap stays visible for follow-up instead of being mistaken for a successful complete journal. -- The desktop purge command now requires an opaque phrase bound to the current proven-Trash set; - changed Trash contents invalidate the approval before deletion, and pending journal failures are - returned as item-level failures without discarding earlier results. -- Local verification for the implementation passed 135 frontend tests, `svelte-check` with zero - diagnostics, 749 Rust library tests (one ignored), and `git diff --check`. Rebuildable temporary - `src-tauri/target` and `node_modules` artifacts were removed from the temporary checkout after - verification; APFS availability measured 36 GiB (96% capacity used) afterward. Active uv, - npm, OpenCode, Podman, File Provider, and user data were not removed. +- The desktop review and purge boundary share an opaque candidate-set phrase; any changed Trash + contents invalidate the approval before the fail-closed operation. Per-item operation and + journal failures are surfaced through bounded customer messages, and successful physical + removal is never reported when its journal record is incomplete. +- Local verification on the current implementation passed 156 frontend tests, `svelte-check` + with zero diagnostics, three focused Rust integration tests, Rust formatting checks for the + changed files, and `git diff --check`. No user file, provider process, cloud object, or native + Trash entry was removed by this verification. - Filename dates remain secondary production evidence: embedded metadata is evaluated first, then an unambiguous filename token such as `2026-04-28` or `251210`, then filesystem times. No cache purge, cloud transfer, or source eviction treats a filename date as authority. From 67786cb593bc52ef476515017c4a08177f1edcbe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 14:24:18 +0900 Subject: [PATCH 063/138] fix: preserve cache cleanup CLI evidence shape --- src-tauri/src/bin/disksage-cache-cleanup.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/bin/disksage-cache-cleanup.rs b/src-tauri/src/bin/disksage-cache-cleanup.rs index e44d013fc..11c6105ba 100644 --- a/src-tauri/src/bin/disksage-cache-cleanup.rs +++ b/src-tauri/src/bin/disksage-cache-cleanup.rs @@ -108,11 +108,17 @@ fn run_with_args(raw_args: impl IntoIterator) -> Result<(), Str return Ok(()); }; if !args.execute { - let cache_trash = if args.purge_proven_cache_trash { - serde_json::to_value(proven_cache_trash_snapshot(&home_directory()?)) - .map_err(|error| error.to_string())? + let (cache_trash, cache_trash_snapshot) = if args.purge_proven_cache_trash { + let snapshot = proven_cache_trash_snapshot(&home_directory()?); + let candidates = + serde_json::to_value(&snapshot.candidates).map_err(|error| error.to_string())?; + let snapshot = serde_json::to_value(snapshot).map_err(|error| error.to_string())?; + (candidates, snapshot) } else { - serde_json::Value::Array(Vec::new()) + ( + serde_json::Value::Array(Vec::new()), + serde_json::Value::Null, + ) }; println!( "{}", @@ -121,6 +127,7 @@ fn run_with_args(raw_args: impl IntoIterator) -> Result<(), Str "journal_path": args.journal_path, "purge_proven_cache_trash": args.purge_proven_cache_trash, "proven_cache_trash": cache_trash, + "proven_cache_trash_snapshot": cache_trash_snapshot, "notice": "pass --execute to perform the guarded OS-Trash operation" }) ); From 83d9022d7e5f8165978deba7c279f3084250d3ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 14:26:35 +0900 Subject: [PATCH 064/138] test: create nested trash paths portably --- src-tauri/src/cache_cleanup.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/cache_cleanup.rs b/src-tauri/src/cache_cleanup.rs index b5032b325..e34db8c14 100644 --- a/src-tauri/src/cache_cleanup.rs +++ b/src-tauri/src/cache_cleanup.rs @@ -505,7 +505,7 @@ mod tests { fn proven_cache_trash_requires_signature_but_permanent_delete_is_unavailable() { let tmp = tempfile::tempdir().unwrap(); let trash = trash_directory(tmp.path()).unwrap(); - fs::create_dir(&trash).unwrap(); + fs::create_dir_all(&trash).unwrap(); let npm = trash.join("_cacache"); fs::create_dir_all(npm.join("content-v2")).unwrap(); fs::create_dir(npm.join("tmp")).unwrap(); @@ -527,7 +527,10 @@ mod tests { assert_eq!(error, PERMANENT_CACHE_TRASH_DELETE_UNAVAILABLE); assert!(npm.exists()); assert!(!journal.exists()); - assert_eq!(approval_phrase, proven_cache_trash_approval_phrase(tmp.path())); + assert_eq!( + approval_phrase, + proven_cache_trash_approval_phrase(tmp.path()) + ); } #[cfg(not(windows))] @@ -550,7 +553,10 @@ mod tests { let journal = tmp.path().join("journal.jsonl"); let error = purge_proven_cache_trash(tmp.path(), &journal, 7, &snapshot).unwrap_err(); assert_eq!(error, PERMANENT_CACHE_TRASH_DELETE_UNAVAILABLE); - assert!(npm.exists(), "reviewed cache must remain without object-bound delete"); + assert!( + npm.exists(), + "reviewed cache must remain without object-bound delete" + ); assert!(pnpm.exists(), "entries added after review must remain"); assert!(!journal.exists()); } From df2a743f6f44603a1029e3a86d3d388599e232cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:30:03 -0700 Subject: [PATCH 065/138] test: fail closed when Organize markup markers are absent --- src/lib/organizeCustomerCopyContract.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/lib/organizeCustomerCopyContract.test.ts b/src/lib/organizeCustomerCopyContract.test.ts index 1dd456ae2..ded413779 100644 --- a/src/lib/organizeCustomerCopyContract.test.ts +++ b/src/lib/organizeCustomerCopyContract.test.ts @@ -8,7 +8,13 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..") describe("Organize customer copy", () => { it("keeps ontology and lineage implementation details out of customer guidance", () => { const source = readFileSync(resolve(repositoryRoot, "src/lib/Organize.svelte"), "utf8"); - const visible = source.slice(source.indexOf(""), source.indexOf("