← The Journey

I Built a Downloads Sorter in Six Hours, and Every Bug in It Looked Like Working Code

Projects 2026-07-29 · Wednesday · 10:40 PM 9 min read 95% AI Eric Li

I didn't set out with a theme. But somewhere around nine at night, after the fourth bug of the day that had run cleanly, printed a success message and done entirely the wrong thing, it got hard to miss: today was about code that looks like it works. It opened at half past one in the morning with a backup that reported "complete" while containing nothing you could restore from, and closed near eleven with a public repository that had to be rebuilt from an empty history.

The backup that had never existed

My main server runs 31 containers. The named volumes underneath them hold the entire update pipeline, every uptime monitor and all the Grafana dashboards, and until this morning none of it was backed up by anything. The script took twenty minutes. Getting it to be true took the rest.

The first run reported success and produced a 1.3 GB archive. GNU tar treats --exclude as positional: an exclusion placed after the file operand is silently ignored. So every exclusion I had written did nothing, the archive was stuffed with live, actively-written database files, and it exited zero. Moving the flags ahead of the operand dropped it to 230 MB.

Then the SQLite snapshots, which I was taking through a read-only mount because read-only felt like the safe choice. It isn't. A write-ahead-log database has to create a -shm sidecar file just to open, and whether one is already resident varies run to run. One database snapshotted fine and failed on the very next run with SQLITE_CANTOPEN. A read-only mount there doesn't fail the backup, it drops a random database out of it and carries on.

So it got proved by restore rather than by exit code. All eight snapshots pass integrity_check with real content in them: 45 monitors, four workflows, the ISO catalogues with 719 and 369 entries. Both failure paths exit 1 and push a DOWN to the status page, including a mountpoint guard so it can't write a convincing-looking backup into an empty local directory if the network mount drops. The first unattended run fired at 03:01:30 — 230 MB, 2004 entries, zero warnings, nobody awake. That was this machine's first real backup.

Three screenshots and a hostile test set

At half four in the afternoon I sent three screenshots of my Downloads folder and asked for something to sort it out. Windows 11, a handful of categories, and one requirement that shaped the whole design: nothing moves until it's been sitting still for an hour, so a download in flight never gets filed mid-write.

The screenshots became the test corpus. Every filename visible in them got transcribed — 118 of them, with 31 distinct extensions, 27 names containing spaces, two with no extension at all, an uppercase .PDF, 4 GB ISOs, a folder mixed in among the files, and names carrying &, +, (1), commas and square brackets. A genuinely hostile test set, which is exactly what you want.

The "wait an hour" part became a rule rather than a timer. A scheduled task runs every 15 minutes and the engine skips anything written to in the last 60, so a reboot or a missed run costs nothing. A file watcher holding a countdown would have looked more elegant and died silently with its process.

Four bugs that read as working code

Move-Item's -Destination is itself a wildcard-expanding parameter, and unlike the source side there is no literal variant of it — -LiteralPath only protects where the file is coming from. Hand it a destination with square brackets in the name and the file goes somewhere you did not ask for. Replaced with a direct .NET file move.

catch [System.IO.IOException] never fired at all, because PowerShell wraps .NET exceptions inside its own MethodInvocationException. The typed catch matched nothing, so all three collision test files fell into failed instead of being renamed with a suffix. It now unwraps the inner exception and asks the filesystem what happened rather than trusting the exception type. Microsoft's own docs say the typed catch does match on Windows PowerShell 5.1, so it's unreliable in both directions.

The same-volume safety check was a no-op. Moves are only instant within one volume, so the code compared path roots first — using GetPathRoot, which is pure string manipulation and never touches the disk. Point a category folder at a junction leading to another drive and the check passes cheerfully while the move quietly crosses volumes. Junctions get rejected outright now, confirmed against a real one rather than reasoned about.

My favourite: on the installer's completion screen, foreach ($l in ...) overwrote $L, the hashtable holding every colour and dimension in the interface, because PowerShell variable names are case-insensitive. The result was Cannot convert null to type System.Drawing.Color, and it only triggered when the "Run Now" box was ticked, which is precisely what I did. The language was being helpful. Fixed by renaming, then by an audit comparing every loop variable in the project against every script-level one, because once is a typo and twice is a pattern.

Two more in the same family: an empty array is falsy in PowerShell, so a guard skipped the exact case it had been written to catch; and the category list, passed comma-joined the way a config file inevitably passes things, got read as one literal folder name, so every file landed in Misc with no error anywhere.

Windows Sandbox, and a test defeated by the thing it was testing

The bug that changed how I was working arrived at 18:14, live on my own PC: Join-Path : Cannot bind argument to parameter 'Path' because it is an empty string. $PSScriptRoot is empty inside a param-block default on Windows PowerShell 5.1 and works fine on PowerShell 7 — and everything up to then had been tested on Linux under 7, which structurally could not produce that failure. So I went looking for a way to stop using my own PC as the test dummy. The answer was an 18-check suite plus a one-command runner that builds a Windows Sandbox, maps the folder read-only and runs everything on a throwaway Windows that evaporates when you close it.

The suite got mutation-tested before I trusted it: three deliberate bugs introduced one at a time — collision suffix removed, age gate removed, ignore list emptied — and each one failed exactly one test, and the correct one. Green ticks are easy; a suite with teeth is not. Scale held too, at 2000 files sorted in 2.4 seconds with 60 sequential collisions numbering themselves cleanly and nothing overwritten.

First Sandbox run came back 17 out of 18, and the failure was in the harness rather than the sorter. Test 15 checks long-path handling with a hard-coded 230-character filename, but the Sandbox's temp directory path is already 72 characters, so the full path came to 306 and Windows refused to create the file. The test died during setup and never reached the code it was testing — defeated by a long path, which is the entire thing it exists to catch. Rewritten to size the name from the actual path at runtime, it passed 18 out of 18 on real Windows PowerShell 5.1.26100.8875.

A real sort in there left exactly five things in the root, all correct: a PDF written a minute before the run, a .part, a .crdownload, Thumbs.db and the log file. The partials had been back-dated on purpose, so watching them get held while newer files moved proved the age gate was making a decision rather than waving everything through.

The honest part: I still don't know it works on my actual Downloads folder. I installed it at quarter to seven, was told the first run would move essentially everything at once, and never went back to look. Every clean result I have is from the Sandbox.

The one-line request that took forty minutes

"Unprivate the repo." That was the whole ask. Before flipping the switch I looked at what would become visible, and the test fixture was the problem — it was real filenames from my own Downloads folder, including documents that belonged to other people and records that should never have left my machine. Not something to publish under my real name.

It got replaced with 118 invented names of the same shape: same spread of extensions, same awkward characters, same edge cases. The tests assert on counts and behaviour and never on specific names, so the swap is behaviour-neutral. Three extra names shifted two counts, the assertions moved with them, and the suite stayed at 18 out of 18.

Cleaning the working tree wasn't enough, because the data was in the history across four of the five commits. Rather than a surgical rewrite I reset to a single clean root commit — one missed blob is worse than lost history on a repo five hours old. Then the local reflog and the remote-tracking refs turned out to still be pinning the old objects, so those had to be expired and pruned too. And a force-push would not have finished the job either, because the old objects stay retrievable by hash on the server until it decides to garbage-collect. So the remote got deleted through the API and recreated from nothing. Verified from an anonymous clone rather than my own: one commit, 22 files, old hashes return 404.

The same lesson had already turned up at one in the morning, in documentation rather than code. Wrapping up a UniFi investigation, I went through my own context file and found it naming a vault path that does not exist and never has. Every instruction I had written about keeping secrets out of the synced folder was aimed at an imaginary line, while the folder that actually syncs had never been treated as the published surface it is. A rule that reads as correct and enforces nothing.

The folder that looked broken and wasn't

The morning also produced a drop folder for screenshots, which sorts by file content rather than by extension. That distinction earned itself immediately: a file named .png that actually contained HTML got detected as text and sent to the failures pile instead of being quietly shelved with the images, where I'd never have found it again.

I did spend a few minutes convinced it was broken, because its root sits empty. It sits empty because the sorter had already filed everything. That was the one thing all day that looked wrong and wasn't.