# Why a disk tool should only ever move to the Trash

> How a Swift disk cleaner stays safe: no delete calls, a path gate, a Trash only rule, tests for the refusals, and an honest list of what the gate misses.

Canonical: https://www.notchstar.com/blog/disk-cleaner-only-move-to-trash-swift/
Last updated: 2026-11-04

*The one gate in NotchStar's Disk tool, what it refuses, the tests that pin it down, and where it is still weaker than it sounds*

Published 2026-11-04.

## TL;DR

- NotchStar's Disk tool has no call that deletes a user's file. Everything it removes goes to the Trash with `FileManager.trashItem`, so every mistake has an undo. This was checked by searching the source on 4 November 2026.
- A 23 line function, `DiskSafety.isTrashable`, decides which paths may be touched at all: things inside the home folder at least two levels down, and app bundles directly inside `/Applications`. Tests pin the refusals.
- The gate checks where a file is, not whether it matters. A file in Documents passes. The safety comes from the Trash and from the user choosing the file, and the post says where that stops being enough.

## The problem with a cleaner that deletes

A disk tool is a program whose job is to remove files, written by someone who cannot see your disk. Its builder does not know which cache is rebuilt for free, which download is the only copy of a purchase, or which folder holds a project that is not backed up. The tool will be wrong sometimes. The design question is what "wrong" costs.

If the tool deletes, a wrong guess is permanent. If it moves to the Trash, a wrong guess is a drag back to the original folder. That difference is the whole argument, and it is cheap to buy. `FileManager.trashItem(at:resultingItemURL:)` is one call, the same operation Finder performs, and it does not need special permissions beyond access to the file.

The free space bought by a delete is also the least valuable kind. The goal is a Mac with room on it, and a person can empty the Trash in one step once they have looked around. Deleting for them skips the part where they notice a mistake.

## How the Disk tool is built

Seven sections sit in one tool: space, find, duplicates, apps, compress, monitor and activity. Most of them can remove something. Every removal ends in the same place. A search of the source on 4 November 2026 gives the evidence:

```bash
grep -rnE "removeItem|unlink|/bin/rm" Sources
```

That finds three lines in the whole app, and none removes a user's file:

| Where | What it removes |
|---|---|
| `MediaCompressor.swift` | Its own hidden temporary file, `.notchstar-<uuid>.<ext>`, next to the file being shrunk |
| `NotchStarApp.swift` | A scratch folder, only when the app was launched for UI testing |
| `FileToolsAddon.swift` | A staging folder the add on made for itself |

And `grep -rn trashItem Sources` finds five lines. Three belong to the Disk tool (`DiskScanner.moveToTrash`, `DiskActions.trash`, and the compressor) and two to the screenshot tray, where dragging a screenshot onto the Trash, or choosing Move to Trash, trashes that one file.

That is the rule in a form anyone can re run: no delete call touches user data. A new contributor, or the author six months later, can run the same search and see if it still holds.

## The gate

Every removal that comes from a list the user browses passes through one function. This is all of it:

```swift
enum DiskSafety {
    static func isTrashable(_ url: URL, home: URL = FileManager.default.homeDirectoryForCurrentUser,
                            applicationsDirs: [URL] = [URL(fileURLWithPath: "/Applications")]) -> Bool {
        let path = url.standardizedFileURL.path
        let homePath = home.standardizedFileURL.path
        if path.hasPrefix(homePath + "/") {
            let parts = path.dropFirst(homePath.count + 1).split(separator: "/")
            // Inside Library, folders like Caches or Preferences are shared containers: only what is inside them.
            return parts.first == "Library" ? parts.count >= 3 : parts.count >= 2
        }
        for dir in applicationsDirs {
            let base = dir.standardizedFileURL.path
            if path.hasPrefix(base + "/"), url.pathExtension == "app",
               path.dropFirst(base.count + 1).split(separator: "/").count == 1 { return true }
        }
        return false
    }
}
```

In words, a path passes in two cases.

1. It is inside the home folder, at least two components down. `~/Documents` has one component below the home folder, so the folder itself is refused, while `~/Documents/a.txt` has two and passes. Inside `~/Library` the bar is one level higher: `~/Library/Caches` is refused, because it is a shared container for every app, and `~/Library/Caches/com.example.app` passes.
2. It is an application bundle, a name ending in `.app`, directly inside `/Applications`. Anything deeper, such as `/Applications/Foo.app/Contents`, is refused, and so is `/Applications` itself.

Everything else is refused: the home folder itself, other users' folders, `/System`, `/Library`, `/tmp`, other volumes. The parameters `home` and `applicationsDirs` exist so the tests can build a fake home folder under a temporary directory instead of aiming at the real one.

`standardizedFileURL` normalizes `..` and `.`, which closes the obvious trick of a path like `/Users/tester/../other/Documents/a.txt` that starts with the right prefix but leaves the home folder.

## The tests that hold it

The disk engines have 26 test functions in `DiskEnginesTests.swift` (counted with `grep -c "func test"`, 4 November 2026). Two matter most for this post.

The first lists the refusals and the passes, one assertion each:

```swift
XCTAssertFalse(ok("/Users/tester"))
XCTAssertFalse(ok("/Users/tester/Documents"))
XCTAssertFalse(ok("/Users/tester/Library/Caches"))
XCTAssertFalse(ok("/System/Library/CoreServices"))
XCTAssertFalse(ok("/Users/other/Documents/a.txt"))
XCTAssertFalse(ok("/Users/tester/../other/Documents/a.txt"))
XCTAssertTrue(ok("/Users/tester/Documents/a.txt"))
XCTAssertTrue(ok("/Users/tester/Library/Caches/com.example.app"))
XCTAssertTrue(DiskSafety.isTrashable(URL(fileURLWithPath: "/Applications/Foo.app"), home: home))
XCTAssertFalse(DiskSafety.isTrashable(URL(fileURLWithPath: "/Applications/Foo.app/Contents"), home: home))
XCTAssertFalse(DiskSafety.isTrashable(URL(fileURLWithPath: "/Applications"), home: home))
```

The second runs the real action against a temporary home folder: it hands `DiskActions.trash` a file that should pass, the Documents folder itself, and `/tmp`, then asserts that only the first one moved, that Documents is still there, and that exactly one activity log entry was written. A gate that is tested only as a pure function can still be bypassed by the code around it. A test that runs the whole path catches that.

## The action

The function that does the removal re checks every path on its own and records what happened:

```swift
for url in urls where DiskSafety.isTrashable(url, home: home) {
    let bytes = DiskScanner.size(of: url)
    if (try? FileManager.default.trashItem(at: url, resultingItemURL: nil)) != nil { moved += bytes; count += 1 }
}
log.record(bytes: moved, items: count, source: source)
```

The check sits inside the loop, so a list that arrives with one bad path is not rejected as a whole. The bad path is skipped and the rest go ahead. The size is measured before the move, because afterwards the item has a new location. The activity log keeps the bytes, the count and which section asked, in plain JSON, so the Activity section can show what has been moved and when.

A symlink needs a word. `trashItem` on a symbolic link moves the link and leaves its target alone. A short Swift script on 4 November 2026 (macOS 27.0.1, Swift 6.4) made a file and a link to it in a temporary folder, trashed the link, and printed:

```text
link gone: true | target still there: true
trashed item is a symlink: true
in user Trash folder: true
```

So a symlink inside the home folder that points at a system folder cannot be used to trash the system folder. The check on the path happens on the link's own location, and the move acts on the link.

## Three places that are not just a path check

**Shrinking media.** The compressor writes the smaller version to a hidden temporary file first. It keeps the result only if it is under 90 percent of the original size (`requiredSavingRatio = 0.9`), and only for images over 2 MB and videos over 50 MB. If the result is worth keeping, the original goes to the Trash and the new file takes its name:

```swift
try FileManager.default.trashItem(at: item.url, resultingItemURL: &trashed)
try FileManager.default.moveItem(at: tmp, to: item.url)
```

The order is deliberate. The original is never touched before a good replacement exists. The weak moment is between the two lines: if the second one fails, the temporary file is removed by the `defer`, and the original is in the Trash. Nothing is lost, but the person would have to put the original back by hand. The gate runs first, with `guard DiskSafety.isTrashable(item.url) else { return 0 }`.

**Uninstalling apps.** The Remove button needs a second press: the first press stores the app in `confirming` and relabels the button to Move to Trash, in red. Apple's apps and NotchStar itself are never listed (`isProtected` is true for any bundle ID starting with `com.apple.` and for the app's own). Leftovers in the Library, such as `Application Support/<name>` and `Caches/<bundle id>`, go through the same `DiskActions.trash`, so each of them has to pass the gate.

**Cleanups by category.** `DiskScanner.moveToTrash` is used for caches, logs, developer folders and old downloads. It does not call `DiskSafety`. It has a different check: an item is accepted only if its parent folder is exactly one of a short list of roots for that category, such as `~/Library/Caches` or `~/Downloads`.

| Path | Entry point | Check before moving |
|---|---|---|
| Space, Find, Duplicates, Apps | `DiskActions.trash` | `DiskSafety.isTrashable` on every URL |
| Compress | `MediaCompressor.compress` | `DiskSafety.isTrashable`, then a smaller copy must exist |
| Overview cleanups | `DiskScanner.moveToTrash` | Parent folder must be a root for that category |

## The wrong turn that is still in the code

The doc comment on `DiskSafety` calls it "the single gate every move to Trash passes through". Reading the code on 4 November 2026 shows that is not quite true. There are two gates. `DiskActions.trash` and the compressor use `isTrashable`. The category cleanups use the parent folder check in `DiskScanner.moveToTrash`. Both are strict, and both are tested, but a reader who trusts the comment and changes only `isTrashable` would leave the second path unchanged. The comment is the part that is wrong.

There is no recorded history of a bug here, so this is not a story about a fix. It is a finding from reading the code carefully before writing about it, and the honest summary is that the "one gate" idea held in intent and drifted in practice. Two cheap repairs are available: make `moveToTrash` call `isTrashable` as well, or correct the comment.

## What is still bad

- **The gate checks location, not value.** `~/Documents/thesis.docx` passes. So does a photo library bundle one level down in Pictures. The filter keeps the tool away from places that are not the user's; it cannot know that a file is precious. That job belongs to the Trash and to the list the person chooses from.
- **The Trash is not a vault.** If the person empties it, the files are gone. Time Machine or another backup is the next line of defence, and the tool does not manage either.
- **Failures are quiet.** `trashItem` can fail, for example on a file owned by another user or on a volume without a Trash. The code uses `try?`, counts only what moved, and the views check `fileExists` afterwards to decide whether to hide the row. The person is not told why a file stayed.
- **Moving is not freeing.** The user interface says "Moved X to the Trash", not "freed". The space comes back when the Trash is emptied. A hard linked duplicate frees nothing even then, which is why the duplicate finder skips hard links.
- **The apps path is wider than it looks.** Leftover candidates are guesses built from the bundle ID and the app name. They are shown for the person to review, and the gate ensures they are inside the home folder, but the names are heuristics.
- **Stop is not a delete, but it is destructive.** The Ports view can send SIGTERM to a process you own after a second press. That is outside the Trash rule, and it is limited to the user's own processes and refuses process 1.
- **Real Macs, many configurations.** The tests use temporary folders. Network volumes, case sensitive volumes and external disks were not part of any recorded run.

## Try it yourself

You can add the same rule to your own tool in four steps.

1. Search your project for every call that removes a file, and make the list explicit:

```bash
grep -rnE "removeItem|unlink|/bin/rm|NSWorkspace.*recycle" Sources
```

2. Put a gate in front of anything that remains. Pass the home folder in as a parameter so a test can use a fake one:

```swift
func mayTouch(_ url: URL, home: URL) -> Bool {
    let path = url.standardizedFileURL.path
    let h = home.standardizedFileURL.path
    guard path.hasPrefix(h + "/") else { return false }
    return path.dropFirst(h.count + 1).split(separator: "/").count >= 2
}
```

3. Write the refusal tests first: the home folder, a top level folder, a sibling user, a path with `..`, a system path. Then the one case that must pass.
4. Route every removal through `trashItem`, and keep the URL it returns so you can log where the item went. Add the search from step 1 to your checklist before each release.

To see the undo with your own eyes, trash a harmless file from a script and then open the Trash in Finder and use Put Back.

## Cheat sheet

| Rule | In NotchStar |
|---|---|
| Delete calls on user files | None (`grep` of `Sources`, 4 November 2026) |
| Removal | `FileManager.trashItem` only |
| Allowed places | Home folder at least 2 levels down, `.app` bundles directly in `/Applications` |
| Library | At least 3 levels down, so `Caches` itself is refused |
| Refused | Home itself, other users, `/System`, `/tmp`, other volumes |
| Symlinks | The link moves, the target stays (measured) |
| Compression | Original goes to the Trash only after a smaller copy exists |
| Apps | Second press to confirm, Apple apps and itself hidden |
| Recorded | Bytes, item count and source in the activity log |
| Known gap | Category cleanups use a second check, not `isTrashable` |

- [ ] Search for every delete call and keep the list short
- [ ] Put one gate in front of every removal and inject the home folder for tests
- [ ] Test refusals as hard as passes
- [ ] Move to the Trash, never delete, and say "moved", not "freed"
- [ ] Create the replacement before trashing the original
- [ ] Make destructive buttons need a second press
- [ ] Write down what the gate does not know

If you want to see how this feels in practice, the Disk tool is part of NotchStar, at [notchstar.com](https://www.notchstar.com/). The safety rules are in one small file, `DiskSafety.swift`, and the tests next to them show exactly what is allowed.

## More posts

- [Sensitive Vercel environment variables cannot be pulled back](https://www.notchstar.com/blog/vercel-sensitive-env-variables-cannot-be-pulled/)
- [A ports view built on lsof: listeners, working directories and a two press Stop](https://www.notchstar.com/blog/macos-lsof-ports-view-two-press-stop/)
