I'm building a disk cleaner for Windows in Rust and Tauri 2. Finding junk isn't the scary part. Deleting it is. If the scanner has a bug, you see a wrong number. If the delete path has a bug, someone loses files.
So the delete path ended up with more code and more tests than anything else in the app. Here's what it does and why. All the snippets are from the real source, trimmed a bit, with the file name on top.
One thing up front: not everything my app removes goes to the Recycle Bin. I'll get to that at the end, because I only fixed how the UI talks about it this week.
1. The webview only gets to delete what a scan returned
In Tauri the UI is a webview calling Rust commands. I treat it like any other client I don't control. If a command takes a list of paths, those paths could be anything.
So the backend remembers what each scan returned, and only those paths can be trashed:
// src-tauri/src/files/guard.rs (simplified)
type Approved = HashMap<ScanKind, HashSet<String>>;
static APPROVED: LazyLock<Mutex<Approved>> = LazyLock::new(Mutex::default);
/// Records the files a scan returned to the UI: only these can be trashed.
/// Protected paths are never recorded.
pub fn approve<'a>(kind: ScanKind, paths: impl IntoIterator<Item = &'a str>) {
let set = paths
.into_iter()
.filter(|path| !is_protected(Path::new(path)))
.map(str::to_owned)
.collect();
approved().insert(kind, set);
}
trash_files checks every path against that set. Anything else is reported as failed and left alone. There's a test for it: a file no scan returned gets reported, and it's still on disk afterwards.
2. "Inside C:\Windows" means folder boundaries
The protected list is what you'd expect. %SystemRoot%, Program Files and ProgramData. $Recycle.Bin and System Volume Information anywhere. pagefile.sys, Recovery, Windows.old and friends at a drive root. Network shares get refused too.
The comparison is where it's easy to slip. A plain starts_with thinks c:\windowsill\notes.txt is inside c:\windows. So paths get normalised first (no \\?\ prefix, lower case, no trailing separator) and then compared on folder boundaries:
// src-tauri/src/files/guard.rs
/// `c:\data` contains `c:\data\x` but not `c:\database`.
pub fn is_within(path: &str, root: &str) -> bool {
match path.strip_prefix(root) {
Some("") => true,
Some(rest) => root.ends_with(SEP) || rest.starts_with(SEP),
None => false,
}
}
Yes, there's a test for C:\Windowsill\notes.txt.
3. A path can change between the scan and the delete
By the time someone clicks "Move to Recycle Bin", the scan might be minutes old. In that time a folder on the path could have been swapped for a junction pointing somewhere else. Scans return resolved paths, so right before deleting I resolve the path again and refuse it if it doesn't resolve to itself anymore:
// src-tauri/src/files/guard.rs
/// A regular file (not a link or folder) outside protected locations whose path
/// resolves to itself. A link or junction put in the way since the scan is refused.
fn safe_size(path: &str) -> Option<u64> {
let meta = Path::new(path).symlink_metadata().ok().filter(std::fs::Metadata::is_file)?;
let real = normalize(&std::fs::canonicalize(path).ok()?);
(real == normalize(Path::new(path)) && !is_protected_normalized(&real)).then_some(meta.len())
}
On Windows canonicalize also expands 8.3 short names like PROGRA~1, which is one more way two strings can mean the same folder. The check runs again for every batch, right before Windows sees the paths, since an earlier batch might have sat on a dialog for a while.
4. The trash crate and COM threads
The actual move is done by the trash crate (5.x). On Windows it initialises COM single-threaded. My app already had other commands (the program list, icons) setting COM to multithreaded on the shared command threads. Call trash on one of those and you don't get an error. You get a panic.
The fix is boring. Every Recycle Bin operation gets its own thread:
// src-tauri/src/recycle.rs
/// `trash` initialises COM single-threaded and *panics* when the thread already uses
/// the multithreaded model. A new thread has no COM state, and a panic there only
/// fails this call.
pub fn on_own_thread<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
std::thread::spawn(f).join().ok()
}
If you've got windows-crate COM code and trash in the same Tauri app, it's worth checking which thread each one runs on.
5. Batches, cancels, and "delete permanently?"
One shell operation per file is slow. One giant operation fails as a whole if a single path is bad. I went with batches of 64, and if a batch fails before it even starts, the code falls back to one file at a time so one bad path doesn't hold up the rest.
Windows has opinions too. If a file is too big for the Recycle Bin, Windows asks whether to delete it permanently. If the user says no, I shouldn't ask again for every remaining file:
// src-tauri/src/files/guard.rs (simplified)
const TRASH_BATCH: usize = 64;
const USER_CANCELLED: [i32; 2] = [
0x8007_04C7_u32 as i32, // HRESULT_FROM_WIN32(ERROR_CANCELLED)
0x8027_0000_u32 as i32, // COPYENGINE_E_USER_CANCELLED
];
for chunk in paths.chunks(TRASH_BATCH) {
let chunk: Vec<&String> = chunk.iter().filter(|p| safe_size(p).is_some()).collect();
match trash::delete_all(&chunk) {
Ok(()) => {}
Err(err) if cancelled_by_user(&err) => return, // the user stopped it
Err(trash::Error::Unknown { .. }) => {} // ran, but the shell skipped some
Err(_) => { /* failed before starting: retry one by one */ }
}
}
6. Let the disk say what moved
The shell's return value doesn't tell you what happened to each file. So after the move, the code just looks. If a path still exists, it's a failure. Only paths that are actually gone count toward "bytes freed":
// src-tauri/src/files/guard.rs
// The file system is the source of truth for what actually moved.
for (path, size) in targets {
if exists(&path) { report.failed.push(path); }
else { report.bytes_freed += size; report.deleted.push(path); }
}
That's why the number the app shows can be smaller than what you picked. Files that were in use stay put and show up as failed.
7. Being honest about what doesn't go to the Recycle Bin
This is the part I got wrong.
Large files, duplicates and similar photos go through everything above. Dev build folders (node_modules, target and so on) also go to the Recycle Bin, through a similar check in recycle.rs. Temp files and caches don't. The cleanup rules in junk_rules/mod.rs delete them with std::fs::remove_file, and the confirm text says why: "These are caches and temporary files; apps rebuild them as needed." What they get instead is a couple of different guards. A rule is skipped while its app is running (the comment in the code: "deleting a live cache can corrupt it"), and One-Click Fix and the scheduler only touch files older than 24 hours.
The code was fine with that. My welcome screen wasn't. It told people deleted files go to the Recycle Bin, which just isn't true for caches.
This week (#103) I swapped the native confirm box for an in-app dialog. It lists what's about to go and shows one of two labels: "Can be restored from the Recycle Bin" or "Permanent — not moved to the Recycle Bin". Cancel has focus by default. The welcome text now says: "Super Cleaner always asks first. Large files go to the Recycle Bin; caches and temp files are deleted for good."
While I was in there I found 11 of my 26 confirms showing English buttons in the Chinese UI. Turns out that one was on me. My old confirmAction() wrapper defaulted to okLabel = "Continue" and cancelLabel = "Cancel", hardcoded in English, and 11 call sites never passed their own labels. Now okLabel is required and Cancel defaults to the translated string.
If you're writing your own delete path
Don't trust the UI with paths, and only delete what the backend found itself. Normalise before you compare, compare on folder boundaries, and resolve again right before the delete. If anything else in your process touches COM, give trash its own thread. Assume the user, or Windows, will cancel halfway. Count what's actually gone from the disk. And tell people plainly what they can and can't undo, because that's the part I almost shipped wrong.
The code's from Super Cleaner, a Windows cleaner I'm building in Rust + Tauri. It's not public yet. I'll post here when it is.
Top comments (0)