Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9a69606
test: expose symlinked cache-root cleanup escape
seonghobae Aug 11, 2026
38346fb
fix: fail closed on symlinked cache roots
seonghobae Aug 11, 2026
0156cb3
fix: bind cache scans to opened directory handles
seonghobae Aug 11, 2026
420da09
security: open cache roots without following symlinks
seonghobae Aug 11, 2026
7f44a70
test: bind cache cleanup execution boundary
seonghobae Aug 11, 2026
20bcd66
fix: bind cache cleanup to one root authority
seonghobae Aug 11, 2026
19be3e6
fix: register atomic cache cleanup command
seonghobae Aug 11, 2026
a668233
fix: keep cache cleanup inside backend boundary
seonghobae Aug 11, 2026
82901d7
test: prove cache cleanup backend boundary
seonghobae Aug 11, 2026
da5a0c1
test: avoid Debug bound in cache cleanup rejection
seonghobae Aug 11, 2026
930c39d
test: prove cache cleanup path race remains
seonghobae Aug 11, 2026
a243e9d
fix: fail closed on cache cleanup path races
seonghobae Aug 11, 2026
4c4e82a
test: bind cache cleanup contract to fail-closed authority
seonghobae Aug 11, 2026
0a1aef1
test: expose fail-closed cache cleanup UX gap
seonghobae Aug 11, 2026
a244e7a
fix: make fail-closed cache cleanup explicit in UI
seonghobae Aug 11, 2026
a3bcab5
test: align cache cleanup flow with read-only UI
seonghobae Aug 11, 2026
0fdfed2
test: preserve outside data for symlinked cache roots
seonghobae Aug 11, 2026
69a1e24
security: reject unsupported desktop targets explicitly
seonghobae Aug 11, 2026
ce6b212
Merge branch 'main' into security/cache-root-symlink-v1
opencode-agent[bot] Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions src-tauri/src/cache_cleanup.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
use std::path::Path;

use crate::{commands::CleanResult, rules};

const ATOMIC_TRASH_UNAVAILABLE: &str = "cache-cleanup-atomic-trash-unavailable";

fn clean_cache_contents_inner(
bases: &rules::BaseDirs,
dir: &Path,
) -> Result<Vec<CleanResult>, String> {
if !rules::is_catalog_path(bases, dir) {
return Err("cache-root-not-current-or-safe".into());
}

// A path-based recycle-bin API cannot preserve the identity of a child entry across the
// final same-user rename/symlink race on every supported desktop platform. Re-validating the
// root immediately before a path-based delete still leaves a check/use window. Until the
// recycle operation itself is bound to the validated filesystem object, refuse cache
// mutation instead of risking moving an unrelated path to the trash.
Err(ATOMIC_TRASH_UNAVAILABLE.into())
}

/// Validate an approved cache root and fail closed until DiskSage has a recycle operation that is
/// bound to the exact validated filesystem object. Read-only cache discovery remains available;
/// this command deliberately grants no destructive authority while path identity can race.
#[cfg(not(coverage))]
#[tauri::command]
pub fn clean_cache_contents(dir: String) -> Result<Vec<CleanResult>, String> {
let bases = rules::BaseDirs::from_env().ok_or("cache-base-directories-unavailable")?;
clean_cache_contents_inner(&bases, Path::new(&dir))
}

#[cfg(test)]
mod tests {
use super::*;
use std::fs;

fn fake_bases(root: &Path) -> rules::BaseDirs {
rules::BaseDirs {
temp: root.join("cache"),
local_data: root.join("local"),
home: root.join("home"),
}
}

#[test]
fn cleanup_rejects_non_catalog_root() {
let tmp = tempfile::tempdir().unwrap();
let bases = fake_bases(tmp.path());
fs::create_dir(&bases.temp).unwrap();

let error = clean_cache_contents_inner(&bases, tmp.path())
.err()
.expect("non-catalog root should be rejected");

assert_eq!(error, "cache-root-not-current-or-safe");
}

#[test]
fn cleanup_refuses_mutation_until_target_identity_can_be_preserved() {
let tmp = tempfile::tempdir().unwrap();
let bases = fake_bases(tmp.path());
fs::create_dir(&bases.temp).unwrap();
let victim = bases.temp.join("keep.bin");
fs::write(&victim, b"keep").unwrap();

let error = clean_cache_contents_inner(&bases, &bases.temp)
.err()
.expect("path-based cache cleanup must fail closed");

assert_eq!(error, ATOMIC_TRASH_UNAVAILABLE);
assert_eq!(fs::read(&victim).unwrap(), b"keep");
assert!(bases.temp.is_dir());
}

#[cfg(unix)]
#[test]
fn cleanup_rejects_symlinked_catalog_root_without_touching_outside_data() {
let tmp = tempfile::tempdir().unwrap();
let bases = fake_bases(tmp.path());
let outside = tmp.path().join("outside");
fs::create_dir(&outside).unwrap();
let outside_file = outside.join("outside.bin");
fs::write(&outside_file, b"outside").unwrap();
std::os::unix::fs::symlink(&outside, &bases.temp).unwrap();

let error = clean_cache_contents_inner(&bases, &bases.temp)
.err()
.expect("symlinked catalog root should be rejected");

assert_eq!(error, "cache-root-not-current-or-safe");
assert_eq!(fs::read(&outside_file).unwrap(), b"outside");
}
}
6 changes: 6 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
compile_error!("DiskSage supports only Windows, Linux, and macOS targets.");

// coverage 빌드(비-테스트)에서는 run()이 빠져 모듈 내용이 테스트에서만 쓰이므로 dead_code만 허용
#[cfg_attr(coverage, allow(dead_code))]
mod dupes;
#[cfg_attr(coverage, allow(dead_code))]
mod commands;
#[cfg_attr(coverage, allow(dead_code))]
mod cache_cleanup;
#[cfg_attr(coverage, allow(dead_code))]
mod scanner;
#[cfg_attr(coverage, allow(dead_code))]
mod userrules;
Expand Down Expand Up @@ -88,6 +93,7 @@ pub fn run() {
commands::list_cache_candidates,
commands::list_dev_artifacts,
commands::clean_paths,
cache_cleanup::clean_cache_contents,
commands::recent_operations,
commands::expand_clean_targets,
commands::find_duplicate_files,
Expand Down
Loading
Loading