Why a disk tool should only ever move to the Trash
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
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:
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:
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.
- It is inside the home folder, at least two components down.
~/Documentshas one component below the home folder, so the folder itself is refused, while~/Documents/a.txthas two and passes. Inside~/Librarythe bar is one level higher:~/Library/Cachesis refused, because it is a shared container for every app, and~/Library/Caches/com.example.apppasses. - 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/Applicationsitself.
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:
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:
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:
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:
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.docxpasses. 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.
trashItemcan fail, for example on a file owned by another user or on a volume without a Trash. The code usestry?, counts only what moved, and the views checkfileExistsafterwards 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.
- Search your project for every call that removes a file, and make the list explicit:
grep -rnE "removeItem|unlink|/bin/rm|NSWorkspace.*recycle" Sources
- 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:
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
}
- 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. - 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. The safety rules are in one small file, DiskSafety.swift, and the tests next to them show exactly what is allowed.
NotchStar is a native Mac notch app, $4 once, with a 7 day free trial. Try it, or read the ten tools.