diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bf76f051..0f18a9eff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Security +- Return a stable cache-cleanup argument error without reflecting an untrusted option payload into + terminal or automation logs; permanent cache-Trash deletion remains fail closed. - Default personal cloud-provider OAuth consent to read-only; upload scope and API write authority now require an explicit user opt-in. - Catalog the Cargo registry source tree as an explicit, identity-bound regenerable-cache target; 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 f6b88990a..91ded685f 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 @@ -1,6 +1,6 @@ # ADR-0002: Cache cleanup is per-item active-use evidence bound -**Status:** Accepted +**Status:** Superseded by ADR-0012 **Date:** 2026-08-20 ## Context @@ -99,5 +99,6 @@ applies to user files or cloud-provider placeholders. ## References - [ADR-0001: Provider evidence drives the cloud-offload Goal](0001-cloud-offload-goal-state.md) +- [ADR-0012: Cache Trash permanent deletion fails closed](0012-cache-trash-permanent-delete-fails-closed.md) - `src-tauri/src/cache_cleanup.rs` - `src-tauri/src/rules.rs` diff --git a/docs/architecture/adr/0012-cache-trash-permanent-delete-fails-closed.md b/docs/architecture/adr/0012-cache-trash-permanent-delete-fails-closed.md new file mode 100644 index 000000000..3b8f281f0 --- /dev/null +++ b/docs/architecture/adr/0012-cache-trash-permanent-delete-fails-closed.md @@ -0,0 +1,84 @@ +# ADR-0012: Cache Trash permanent deletion fails closed + +**Status:** Proposed +**Date:** 2026-09-03 +**Supersedes:** ADR-0002 only for permanent deletion of cache entries already in OS Trash + +## Context + +ADR-0002 allowed a separate `--execute --purge-proven-cache-trash` path to permanently remove +structurally recognized cache directories from operating-system Trash after pathname-based +revalidation. Subsequent implementation review showed that the final irreversible deletion syscall +was not bound to the exact reviewed filesystem object. A pathname can be replaced after review and +before recursive removal, so the earlier policy could not satisfy DiskSage's deletion-safety +boundary even when the candidate name, structure, size, and symlink checks were repeated. + +DiskSage already has a reversible, identity-bound cleanup path that moves inactive regenerable cache +children into OS Trash. Permanent removal is different: once Trash is bypassed there is no product +undo boundary, so evidence that is sufficient for staging is not sufficient for irreversible +deletion. + +## Decision + +DiskSage does not perform in-app permanent deletion of reviewed cache-Trash entries until the final +irreversible filesystem operation can be bound to the exact object that was reviewed and approved. + +- `--purge-proven-cache-trash` remains a read-only evidence operation. +- `--execute --purge-proven-cache-trash` returns + `cache-trash-identity-bound-permanent-delete-unavailable` before journal or filesystem mutation. +- The library boundary also fails closed and does not call pathname-recursive permanent-deletion + primitives. +- Candidate names, signatures, byte counts, and approval phrases are review evidence only; they do + not create irreversible mutation authority. +- Operators who intend permanent reclaim must inspect the candidate evidence and empty the native + Trash manually through the operating system. DiskSage does not claim those bytes as physically + reclaimed until the operating system reports the resulting availability change. +- User files, cloud-provider placeholders, and arbitrary Trash entries remain outside this cache + evidence path. + +This decision leaves ADR-0002's per-item active-use checks and reversible OS-Trash staging intact. +Only its separate permanent-delete authorization is superseded. + +## Consequences + +- The CLI and desktop remain conservative under disk pressure: they can identify regenerable cache + material but cannot silently turn that evidence into an irreversible delete. +- Automation receives a stable refusal code rather than a partial journal or ambiguous success + receipt. +- Physical space recovery may require an explicit operating-system Trash action after DiskSage has + completed its reversible cleanup. +- A future permanent-delete capability requires a new or superseding ADR, a real object-bound + deletion primitive for each supported platform, race/alias/mount/hardlink tests, recovery and + audit semantics, and current-head release evidence before it can become Accepted. + +## Alternatives rejected + +- **Keep pathname revalidation plus recursive deletion.** Rejected because repeated pathname checks + do not bind the final syscall to the reviewed object and leave a check/use race at an irreversible + boundary. +- **Treat a candidate-set approval phrase as delete authority.** Rejected because a phrase proves + what the user reviewed, not that the pathname still names the same filesystem object at mutation + time. +- **Delete first and rely on the journal for recovery.** Rejected because a journal cannot restore an + object after a genuinely permanent delete and journal failure can itself occur after mutation. +- **Broaden automatic cleanup instead of using Trash.** Rejected because reversible OS-Trash staging + is the product's established safety and recovery boundary for regenerable cache content. + +## Evidence and acceptance + +The production CLI regression creates a real cache-shaped directory under a temporary Trash, +invokes `--execute --purge-proven-cache-trash`, and requires the refusal code while proving both the +cache object and journal remain untouched. Documentation contract coverage requires the runbook and +ADR index to describe the same fail-closed behavior. + +This ADR remains Proposed while the implementing PR is unmerged. Acceptance requires an unchanged +exact head with the repository's required tests, security gates, coverage, review, and release +verification all passing under live protection rules. + +## References + +- [ADR-0002: Cache cleanup is per-item active-use evidence bound](0002-cache-cleanup-is-per-item-evidence-bound.md) +- `src-tauri/src/bin/disksage-cache-cleanup.rs` +- `src-tauri/src/cache_cleanup.rs` +- `src-tauri/tests/cache_cleanup_cli_purge_fail_closed.rs` +- [Cache cleanup operator runbook](../../development/cache-cleanup-operator-runbook.md) diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index e6735fc57..0bed07ad2 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -7,7 +7,7 @@ new numbered record rather than rewriting history. | ADR | Decision | Status | | --- | --- | --- | | [0001](0001-cloud-offload-goal-state.md) | Provider evidence drives the cloud-offload Goal | Accepted | -| [0002](0002-cache-cleanup-is-per-item-evidence-bound.md) | Cache cleanup is per-item evidence-bound | Accepted | +| [0002](0002-cache-cleanup-is-per-item-evidence-bound.md) | Cache cleanup is per-item evidence-bound | Superseded by 0012 for permanent cache-Trash deletion | | [0003](0003-zotero-local-api-metadata-handoff.md) | Zotero Local API metadata handoff | Accepted | | [0004](0004-bounded-maintenance-command-execution.md) | Bounded maintenance command execution | Accepted | | [0005](0005-hourly-agent-loop-is-advisory.md) | Hourly agent loop is advisory | Superseded by 0008 | @@ -17,6 +17,7 @@ new numbered record rather than rewriting history. | [0009](0009-path-free-lineage-relation-graph.md) | Export a path-free lineage relation graph | Accepted | | [0010](0010-rooted-organize-destinations.md) | Require rooted, process-independent organize destinations | Accepted | | [0011](0011-cloud-transfer-failure-and-materialization.md) | Durable failed-copy evidence and placeholder-safe adoption | Accepted | +| [0012](0012-cache-trash-permanent-delete-fails-closed.md) | Cache Trash permanent deletion fails closed | Proposed | New records must state context, decision, consequences, rejected alternatives, and the evidence or standard that led to the decision. A record never grants cloud-write or source-eviction authority; diff --git a/docs/development/cache-cleanup-operator-runbook.md b/docs/development/cache-cleanup-operator-runbook.md index 36d515111..274689638 100644 --- a/docs/development/cache-cleanup-operator-runbook.md +++ b/docs/development/cache-cleanup-operator-runbook.md @@ -26,10 +26,15 @@ If the Trash is consuming space, inspect only structurally proven cache entries `cargo run --locked --manifest-path src-tauri/Cargo.toml --bin disksage-cache-cleanup -- --purge-proven-cache-trash --journal-path /ABSOLUTE/journal.jsonl` -The command is read-only until both `--execute` and `--purge-proven-cache-trash` are supplied. -That explicit path permanently removes only the known cache signatures already in OS Trash; it -does not empty Trash generally and never targets cloud placeholders or user files. Review its JSON -result and journal before treating the reported bytes as reclaimed. +This command is read-only evidence. Supplying `--execute` together with +`--purge-proven-cache-trash` is intentionally refused with +`cache-trash-identity-bound-permanent-delete-unavailable` before journal or filesystem mutation, +because DiskSage does not yet have an object-bound primitive for the final irreversible deletion +syscall. Review the JSON candidate evidence, then empty the native Trash manually through the +operating system when permanent reclaim is intended. Do not treat candidate logical bytes as +physically reclaimed until the operating system reports the resulting availability change. +DiskSage never treats this review path as authority to empty Trash generally or to mutate cloud +placeholders or user files. The cache catalog includes the macOS `uv`, Hugging Face, Codex runtime, Gradle, npm, pip, and Cargo registry cache/source roots when present. The Cargo registry source root is catalogued for explicit diff --git a/src-tauri/src/bin/disksage-cache-cleanup.rs b/src-tauri/src/bin/disksage-cache-cleanup.rs index 82194a62a..89ac05d48 100644 --- a/src-tauri/src/bin/disksage-cache-cleanup.rs +++ b/src-tauri/src/bin/disksage-cache-cleanup.rs @@ -3,16 +3,24 @@ //! 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_candidates, 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."; + +fn read_only_notice(purge_proven_cache_trash: bool) -> &'static str { + if purge_proven_cache_trash { + "proven cache-Trash review is read-only; empty the native Trash manually to reclaim space; --execute cannot enable permanent deletion" + } else { + "pass --execute to move guarded cache children to OS Trash" + } +} #[derive(Debug, PartialEq, Eq)] struct Args { @@ -69,12 +77,31 @@ fn parse_args(raw_args: impl IntoIterator) -> Result execute = true, - Some("--purge-proven-cache-trash") => purge_proven_cache_trash = true, + Some("--execute") => { + if seen_execute { + return Err("--execute may be supplied once".into()); + } + seen_execute = true; + execute = true; + } + Some("--purge-proven-cache-trash") => { + if seen_purge_proven_cache_trash { + return Err("--purge-proven-cache-trash may be supplied once".into()); + } + seen_purge_proven_cache_trash = true; + purge_proven_cache_trash = true; + } Some("--journal-path") => { + if seen_journal_path { + return Err("--journal-path may be supplied once".into()); + } + seen_journal_path = true; journal_path = PathBuf::from( args.next() .ok_or_else(|| "--journal-path requires PATH".to_string())?, @@ -84,7 +111,7 @@ fn parse_args(raw_args: impl IntoIterator) -> Result return Err(format!("--help must be used alone\n{USAGE}")), - Some(value) => return Err(format!("unknown option: {value}\n{USAGE}")), + Some(_) => return Err(format!("cache-cleanup-invalid-argument\n{USAGE}")), None => return Err(format!("invalid UTF-8 option\n{USAGE}")), } } @@ -108,11 +135,18 @@ 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_candidates(&home_directory()?)) - .map_err(|error| error.to_string())? + let notice = read_only_notice(args.purge_proven_cache_trash); + 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,27 +155,18 @@ 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, - "notice": "pass --execute to perform the guarded OS-Trash operation" + "proven_cache_trash_snapshot": cache_trash_snapshot, + "notice": notice }) ); 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 results = purge_proven_cache_trash(&home_directory()?, &args.journal_path, now_ms())?; - 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!( "{}", @@ -172,11 +197,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")); } @@ -190,6 +212,34 @@ mod tests { assert_eq!(error, "--journal-path must be absolute"); } + #[test] + fn duplicate_authority_singletons_are_rejected() { + let duplicate_execute = parse_args([ + OsString::from("--execute"), + OsString::from("--execute"), + ]) + .unwrap_err(); + assert_eq!(duplicate_execute, "--execute may be supplied once"); + + let duplicate_purge = parse_args([ + OsString::from("--purge-proven-cache-trash"), + OsString::from("--purge-proven-cache-trash"), + ]) + .unwrap_err(); + assert_eq!( + duplicate_purge, + "--purge-proven-cache-trash may be supplied once" + ); + } + + #[test] + fn unknown_argument_is_not_reflected() { + let payload = "--unknown-with-sensitive-value"; + let error = parse_args([OsString::from(payload)]).unwrap_err(); + assert!(error.contains("cache-cleanup-invalid-argument")); + assert!(!error.contains(payload)); + } + #[test] fn purge_cache_trash_flag_is_explicit() { let args = parse_args([OsString::from("--purge-proven-cache-trash")]) @@ -198,4 +248,12 @@ mod tests { assert!(!args.execute); assert!(args.purge_proven_cache_trash); } + + #[test] + fn read_only_notice_matches_the_requested_action() { + assert!(read_only_notice(false).contains("pass --execute")); + assert!(read_only_notice(true).contains("read-only")); + assert!(read_only_notice(true).contains("empty the native Trash")); + assert!(!read_only_notice(true).contains("pass --execute to move")); + } } diff --git a/src-tauri/src/cache_cleanup.rs b/src-tauri/src/cache_cleanup.rs index 673ae3d7b..275073f94 100644 --- a/src-tauri/src/cache_cleanup.rs +++ b/src-tauri/src/cache_cleanup.rs @@ -19,10 +19,9 @@ pub const AUTO_REGENERABLE_CACHE_IDS: [&str; 6] = [ "trivy-cache", ]; -const PROVEN_CACHE_TRASH_NAMES: [&str; 9] = [ +const PROVEN_CACHE_TRASH_NAMES: [&str; 8] = [ "_cacache", "v11", - "Default", "simple-v21", "typequest", "wheels-v6", @@ -31,6 +30,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. @@ -43,15 +44,15 @@ pub struct CacheTrashCandidate { pub signature: String, } +/// Candidate list and approval token produced by one Trash scan. +/// +/// 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 CacheTrashPurgeResult { - pub name: String, - pub path: String, - pub bytes: u64, - pub signature: String, - pub purged: bool, - pub error: String, +pub struct CacheTrashSnapshot { + pub candidates: Vec, + pub approval_phrase: String, } fn direct_child_is_dir(path: &Path, name: &str) -> bool { @@ -79,11 +80,6 @@ fn looks_like_proven_cache_trash(path: &Path, name: &str) -> Option<&'static str { "pnpm-store-v11" } - "Default" - if direct_child_is_dir(path, "Cache") && direct_child_is_dir(path, "Code Cache") => - { - "edge-profile-cache" - } "simple-v21" if direct_child_is_dir(path, "pypi") => "uv-simple-index-cache", "typequest" if direct_child_is_dir(path, "common") && direct_child_is_dir(path, ".2") => { "uv-typequest-cache" @@ -142,10 +138,51 @@ fn bounded_tree_size(path: &Path, entries: &mut usize) -> Result { Ok(total) } +#[cfg(all(unix, not(target_os = "macos")))] +fn unix_trash_directory( + home: &Path, + configured_home: Option<&Path>, + xdg_data_home: Option<&Path>, +) -> PathBuf { + xdg_data_home + .filter(|path| path.is_absolute()) + .filter(|_| configured_home == Some(home)) + .map(Path::to_path_buf) + .unwrap_or_else(|| home.join(".local").join("share")) + .join("Trash") + .join("files") +} + +pub(crate) fn trash_directory(home: &Path) -> Option { + #[cfg(target_os = "macos")] + { + return Some(home.join(".Trash")); + } + #[cfg(all(unix, not(target_os = "macos")))] + { + // XDG_DATA_HOME belongs to the configured user home. Tests and callers that + // inspect an alternate home must remain hermetic and use that home's default. + let configured_home = std::env::var_os("HOME").map(PathBuf::from); + let xdg_data_home = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from); + return Some(unix_trash_directory( + home, + configured_home.as_deref(), + xdg_data_home.as_deref(), + )); + } + #[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(); }; @@ -174,50 +211,58 @@ pub fn proven_cache_trash_candidates(home: &Path) -> Vec { candidates } -/// 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(crate) fn approval_phrase_for_candidates(candidates: &[CacheTrashCandidate]) -> String { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"disksage.cache-trash-purge-approval.v1\0"); + let mut ordered = candidates.to_vec(); + ordered.sort_by(|left, right| left.path.cmp(&right.path)); + for candidate in ordered { + 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()); + } + hasher.update(&candidate.bytes.to_le_bytes()); + } + format!( + "DiskSage cache-trash purge approval {}", + hasher.finalize().to_hex() + ) +} + +/// 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 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 +} + +/// 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, -) -> Result, String> { - let planned = proven_cache_trash_candidates(home); - let mut results = Vec::with_capacity(planned.len()); - for candidate in planned { - 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(), - }; - crate::safety::journal_append(journal_path, &entry).map_err(|error| error.to_string())?; - let outcome = if 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}"), - }; - crate::safety::journal_append(journal_path, &entry).map_err(|error| error.to_string())?; - 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(), - }); + _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()); } - Ok(results) + Err(PERMANENT_CACHE_TRASH_DELETE_UNAVAILABLE.into()) } fn active_use_blocker( @@ -338,7 +383,6 @@ pub fn clean_regenerable_caches_headless( } /// Read the exact cache children that may be included in a later identity-bound Trash request. -#[cfg(not(coverage))] #[tauri::command] pub fn list_cache_targets(dir: String) -> Result, String> { let bases = rules::BaseDirs::from_env().ok_or("cache-base-directories-unavailable")?; @@ -349,7 +393,6 @@ pub fn list_cache_targets(dir: String) -> Result, String } /// Move only the reviewed cache children to the OS Trash, retaining the cache root itself. -#[cfg(not(coverage))] #[tauri::command] pub fn clean_cache_contents( dir: String, @@ -394,6 +437,38 @@ mod tests { assert_eq!(error, "cache-root-not-current-or-safe"); } + #[test] + fn cleanup_rejects_provider_managed_cache_root_without_mutation() { + let tmp = tempfile::tempdir().unwrap(); + let provider_cache = tmp + .path() + .join("Library/CloudStorage/OneDrive-Personal/cache"); + fs::create_dir_all(&provider_cache).unwrap(); + let victim = provider_cache.join("artifact.bin"); + fs::write(&victim, b"provider-content").unwrap(); + let bases = rules::BaseDirs { + temp: provider_cache.clone(), + local_data: tmp.path().join("local"), + home: tmp.path().join("home"), + }; + let journal = tmp.path().join("journal.jsonl"); + let targets = rules::cache_targets(&provider_cache).unwrap(); + + let error = clean_cache_contents_inner( + &bases, + &provider_cache, + &targets, + &journal, + 1, + ) + .err() + .expect("provider-managed cache roots must remain outside cleanup authority"); + + assert_eq!(error, "cache-root-not-current-or-safe"); + assert_eq!(fs::read(&victim).unwrap(), b"provider-content"); + assert!(!journal.exists()); + } + #[test] fn cleanup_rejects_stale_target_snapshot_without_mutation() { let tmp = tempfile::tempdir().unwrap(); @@ -444,11 +519,27 @@ mod tests { ); } + #[cfg(all(unix, not(target_os = "macos")))] #[test] - fn proven_cache_trash_requires_signature_and_journals_purge() { + fn unix_trash_directory_uses_xdg_data_home_for_configured_home() { + let home = Path::new("/home/test-user"); + let xdg = Path::new("/mnt/data"); + assert_eq!( + unix_trash_directory(home, Some(home), Some(xdg)), + PathBuf::from("/mnt/data/Trash/files") + ); + assert_eq!( + unix_trash_directory(home, Some(Path::new("/home/other")), Some(xdg)), + PathBuf::from("/home/test-user/.local/share/Trash/files") + ); + } + + #[cfg(not(windows))] + #[test] + fn proven_cache_trash_requires_signature_but_permanent_delete_is_unavailable() { let tmp = tempfile::tempdir().unwrap(); - let trash = tmp.path().join(".Trash"); - fs::create_dir(&trash).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(); @@ -456,20 +547,80 @@ mod tests { let unrelated = trash.join("Default"); fs::create_dir(&unrelated).unwrap(); fs::create_dir(unrelated.join("Cache")).unwrap(); + fs::create_dir(unrelated.join("Code Cache")).unwrap(); let candidates = proven_cache_trash_candidates(tmp.path()); assert_eq!(candidates.len(), 1); assert_eq!(candidates[0].signature, "npm-cacache"); assert_eq!(candidates[0].bytes, 5); + assert!( + candidates + .iter() + .all(|candidate| candidate.name != "Default"), + "a browser profile root must never be exposed as a cache candidate" + ); + 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 snapshot = proven_cache_trash_snapshot(tmp.path()); + 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 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(); + 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).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\"")); + 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))] + #[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)] diff --git a/src-tauri/src/cloud.rs b/src-tauri/src/cloud.rs index 8eb339654..a626a49b2 100644 --- a/src-tauri/src/cloud.rs +++ b/src-tauri/src/cloud.rs @@ -4154,7 +4154,7 @@ fn source_blocked_reason( /// File Provider's private storage and download staging trees are owned by macOS. Their files /// are implementation state, not user payloads; only a provider-aware operation may reclaim them. -fn path_inside_managed_file_provider_storage(path: &Path) -> bool { +pub(crate) fn path_inside_managed_file_provider_storage(path: &Path) -> bool { let mut previous = String::new(); path.components().any(|component| { let name = normalized_account_text(&component.as_os_str().to_string_lossy()); diff --git a/src-tauri/src/rules.rs b/src-tauri/src/rules.rs index 32d0fce10..fd064982e 100644 --- a/src-tauri/src/rules.rs +++ b/src-tauri/src/rules.rs @@ -272,6 +272,18 @@ impl CatalogRoot { return None; } + // Ancestor symlinks/reparse points may resolve a lexically safe catalog path into a + // managed File Provider tree. Resolve only after binding the directory handle, then open + // the resolved path and require it to identify that same object before trusting the + // resolved provider boundary. A concurrent pathname swap therefore fails closed. + let resolved_path = std::fs::canonicalize(path).ok()?; + let resolved = open_directory_handle(&resolved_path)?; + if handle != resolved + || crate::cloud::path_inside_managed_file_provider_storage(&resolved_path) + { + return None; + } + Some(Self { handle, display_path: path.to_path_buf(), @@ -473,6 +485,7 @@ impl CatalogRoot { pub fn cache_candidates(bases: &BaseDirs) -> Vec { catalog(bases) .into_iter() + .filter(|(_, _, path)| !crate::cloud::path_inside_managed_file_provider_storage(path)) .map(|(id, label, path)| { let root = CatalogRoot::open(&path); let exists = root.is_some(); @@ -490,7 +503,9 @@ pub fn cache_candidates(bases: &BaseDirs) -> Vec { /// dir이 현재 카탈로그가 가리키는 경로인지 (expand_clean_targets의 스코프 검증용 — 크기 계산 없음) pub fn is_catalog_path(bases: &BaseDirs, dir: &Path) -> bool { - catalog(bases).iter().any(|(_, _, p)| p == dir) && CatalogRoot::open(dir).is_some() + !crate::cloud::path_inside_managed_file_provider_storage(dir) + && catalog(bases).iter().any(|(_, _, p)| p == dir) + && CatalogRoot::open(dir).is_some() } /// 캐시 디렉토리 자체는 보존하고 내용물만 비우기 위한 직계 자식 열거. diff --git a/src-tauri/tests/cache_candidate_provider_boundary.rs b/src-tauri/tests/cache_candidate_provider_boundary.rs new file mode 100644 index 000000000..4fe2ba783 --- /dev/null +++ b/src-tauri/tests/cache_candidate_provider_boundary.rs @@ -0,0 +1,56 @@ +use disksage_lib::rules::{cache_candidates, BaseDirs}; + +#[test] +fn cache_candidates_hide_managed_file_provider_roots() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let managed_temp = temporary + .path() + .join("File Provider Storage") + .join("cache"); + std::fs::create_dir_all(&managed_temp).expect("managed provider cache fixture"); + std::fs::write(managed_temp.join("provider-state.bin"), b"provider-state") + .expect("managed provider cache contents"); + + let bases = BaseDirs { + temp: managed_temp, + local_data: temporary.path().join("local"), + home: temporary.path().join("home"), + }; + + let candidates = cache_candidates(&bases); + + assert!( + !candidates.iter().any(|candidate| candidate.id == "os-temp"), + "managed File Provider storage must not be advertised as an actionable cache candidate" + ); +} + +#[cfg(unix)] +#[test] +fn cache_candidates_hide_managed_file_provider_roots_reached_through_symlinked_ancestor() { + use std::os::unix::fs::symlink; + + let temporary = tempfile::tempdir().expect("temporary directory"); + let managed_parent = temporary.path().join("File Provider Storage"); + let managed_temp = managed_parent.join("cache"); + std::fs::create_dir_all(&managed_temp).expect("managed provider cache fixture"); + std::fs::write(managed_temp.join("provider-state.bin"), b"provider-state") + .expect("managed provider cache contents"); + + let alias = temporary.path().join("cache-alias"); + symlink(&managed_parent, &alias).expect("provider ancestor symlink fixture"); + let aliased_temp = alias.join("cache"); + + let bases = BaseDirs { + temp: aliased_temp, + local_data: temporary.path().join("local"), + home: temporary.path().join("home"), + }; + + let candidates = cache_candidates(&bases); + + assert!( + !candidates.iter().any(|candidate| candidate.id == "os-temp"), + "a symlinked ancestor must not make managed File Provider storage actionable" + ); +} 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..f08013636 --- /dev/null +++ b/src-tauri/tests/cache_cleanup_cli_purge_fail_closed.rs @@ -0,0 +1,180 @@ +#[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")))] +#[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" + ); +} + +#[cfg(all(unix, not(target_os = "macos")))] +#[test] +fn shipped_cli_honors_xdg_data_home_for_read_only_trash_evidence() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let xdg_data_home = temp.path().join("xdg-data"); + let trash = xdg_data_home.join("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 default_trash = home.join(".local/share/Trash/files/v11"); + fs::create_dir_all(default_trash.join("metadata")).unwrap(); + fs::create_dir(default_trash.join("metadata-full")).unwrap(); + + let journal = temp.path().join("state/journal.jsonl"); + let output = Command::new(env!("CARGO_BIN_EXE_disksage-cache-cleanup")) + .env("HOME", &home) + .env("XDG_DATA_HOME", &xdg_data_home) + .args(["--purge-proven-cache-trash", "--journal-path"]) + .arg(&journal) + .output() + .unwrap(); + + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let evidence: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let candidates = evidence["proven_cache_trash"].as_array().unwrap(); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0]["name"], "_cacache"); + assert_eq!(candidates[0]["path"], npm.to_string_lossy().as_ref()); + assert!( + !candidates.iter().any(|candidate| candidate["name"] == "v11"), + "custom XDG_DATA_HOME must take precedence over the default home Trash" + ); + assert!( + !journal.exists(), + "read-only evidence collection must not create the journal" + ); +} + +#[cfg(all(unix, not(target_os = "macos")))] +#[test] +fn shipped_cli_rejects_duplicate_authority_singletons_before_side_effects() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + fs::create_dir_all(&home).unwrap(); + let journal_a = temp.path().join("state/a.jsonl"); + let journal_b = temp.path().join("state/b.jsonl"); + + let duplicate_journal = Command::new(env!("CARGO_BIN_EXE_disksage-cache-cleanup")) + .env("HOME", &home) + .env_remove("XDG_DATA_HOME") + .arg("--journal-path") + .arg(&journal_a) + .arg("--journal-path") + .arg(&journal_b) + .output() + .unwrap(); + assert_eq!(duplicate_journal.status.code(), Some(2)); + assert!(duplicate_journal.stdout.is_empty()); + assert!(String::from_utf8(duplicate_journal.stderr) + .unwrap() + .contains("--journal-path may be supplied once")); + + let duplicate_purge = Command::new(env!("CARGO_BIN_EXE_disksage-cache-cleanup")) + .env("HOME", &home) + .env_remove("XDG_DATA_HOME") + .args(["--purge-proven-cache-trash", "--purge-proven-cache-trash"]) + .output() + .unwrap(); + assert_eq!(duplicate_purge.status.code(), Some(2)); + assert!(duplicate_purge.stdout.is_empty()); + assert!(String::from_utf8(duplicate_purge.stderr) + .unwrap() + .contains("--purge-proven-cache-trash may be supplied once")); + + let duplicate_execute = Command::new(env!("CARGO_BIN_EXE_disksage-cache-cleanup")) + .env("HOME", &home) + .env_remove("XDG_DATA_HOME") + .args([ + "--execute", + "--execute", + "--purge-proven-cache-trash", + ]) + .output() + .unwrap(); + assert_eq!(duplicate_execute.status.code(), Some(2)); + assert!(duplicate_execute.stdout.is_empty()); + assert!(String::from_utf8(duplicate_execute.stderr) + .unwrap() + .contains("--execute may be supplied once")); + + assert!(!journal_a.exists()); + assert!(!journal_b.exists()); +} + +#[test] +fn operator_docs_match_the_fail_closed_permanent_delete_contract() { + let repository_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("src-tauri must live directly under the repository root"); + let runbook = std::fs::read_to_string( + repository_root.join("docs/development/cache-cleanup-operator-runbook.md"), + ) + .unwrap(); + let legacy_adr = std::fs::read_to_string( + repository_root.join( + "docs/architecture/adr/0002-cache-cleanup-is-per-item-evidence-bound.md", + ), + ) + .unwrap(); + let adr_index = std::fs::read_to_string(repository_root.join("docs/architecture/adr/README.md")) + .unwrap(); + let current_adr = std::fs::read_to_string(repository_root.join( + "docs/architecture/adr/0012-cache-trash-permanent-delete-fails-closed.md", + )) + .unwrap(); + + assert!(runbook.contains( + "cache-trash-identity-bound-permanent-delete-unavailable" + )); + assert!(runbook.contains("empty the native Trash manually")); + assert!(!runbook.contains("permanently removes only")); + + assert!(legacy_adr.contains("**Status:** Superseded by ADR-0012")); + assert!(adr_index.contains( + "0012-cache-trash-permanent-delete-fails-closed.md" + )); + assert!(adr_index.contains("Cache Trash permanent deletion fails closed")); + assert!(adr_index.contains("Proposed")); + + assert!(current_adr.contains("**Status:** Proposed")); + assert!(current_adr.contains( + "cache-trash-identity-bound-permanent-delete-unavailable" + )); + assert!(current_adr.contains("before journal or filesystem mutation")); + assert!(!current_adr.contains("permanently removes only")); + assert!(!current_adr.contains("may permanently remove only")); +} diff --git a/src-tauri/tests/cache_cleanup_coverage_runtime_contract.rs b/src-tauri/tests/cache_cleanup_coverage_runtime_contract.rs new file mode 100644 index 000000000..9dc9f77a8 --- /dev/null +++ b/src-tauri/tests/cache_cleanup_coverage_runtime_contract.rs @@ -0,0 +1,5 @@ +#[test] +fn shipped_cache_cleanup_handlers_remain_available_to_instrumented_builds() { + let _list_cache_targets = disksage_lib::cache_cleanup::list_cache_targets; + let _clean_cache_contents = disksage_lib::cache_cleanup::clean_cache_contents; +} diff --git a/src-tauri/tests/cache_provider_alias_fail_closed.rs b/src-tauri/tests/cache_provider_alias_fail_closed.rs new file mode 100644 index 000000000..c06518e43 --- /dev/null +++ b/src-tauri/tests/cache_provider_alias_fail_closed.rs @@ -0,0 +1,76 @@ +#[cfg(unix)] +mod cloud { + use std::path::Path; + + pub(crate) fn path_inside_managed_file_provider_storage(path: &Path) -> bool { + let mut previous = String::new(); + path.components().any(|component| { + let name = component + .as_os_str() + .to_string_lossy() + .trim() + .to_lowercase(); + let managed = name == "file provider storage" + || (previous == "library" + && matches!(name.as_str(), "mobile documents" | "cloudstorage")) + || (previous == "application support" && name == "fileprovider"); + previous = name; + managed + }) + } +} + +#[cfg(unix)] +mod safety { + use std::path::Path; + + pub(crate) fn filesystem_object_id(path: &Path) -> std::io::Result { + Ok(path.to_string_lossy().into_owned()) + } +} + +#[cfg(unix)] +#[path = "../src/rules.rs"] +mod production_rules; + +#[cfg(unix)] +mod provider_alias_contract { + use super::production_rules; + use std::fs; + use std::path::Path; + + #[test] + fn symlinked_ancestor_into_managed_provider_storage_never_gets_cleanup_authority() { + let temp = tempfile::tempdir().expect("temp root"); + let home = temp.path().join("home"); + let managed_parent = home + .join("Library") + .join("CloudStorage") + .join("Provider"); + let managed_cache = managed_parent.join("cache"); + fs::create_dir_all(&managed_cache).expect("managed cache fixture"); + fs::write(managed_cache.join("customer-owned.bin"), b"keep") + .expect("managed payload fixture"); + + let alias_parent = temp.path().join("cache-alias"); + std::os::unix::fs::symlink(&managed_parent, &alias_parent).expect("ancestor alias"); + let aliased_cache = alias_parent.join("cache"); + let bases = production_rules::BaseDirs { + temp: aliased_cache.clone(), + local_data: temp.path().join("local"), + home, + }; + + let candidate = production_rules::cache_candidates(&bases) + .into_iter() + .find(|candidate| candidate.id == "os-temp") + .expect("os-temp catalog entry"); + assert!( + !candidate.exists, + "a cache reached through a symlinked ancestor into managed provider storage must not be actionable" + ); + assert_eq!(candidate.bytes, 0); + assert!(!production_rules::is_catalog_path(&bases, Path::new(&aliased_cache))); + assert!(production_rules::clean_targets(&aliased_cache).is_empty()); + } +}