fix: skip a file the hashing pass cannot read instead of aborting [patch] - #129
Conversation
…tch] FileHasher.HashFiles caught only IOException, and UnauthorizedAccessException does not derive from it. Hashing runs under Parallel.ForEach, so a single permission-denied file -- an OS-protected file, or one owned by another user -- escaped the delegate and surfaced as an AggregateException out of HashFiles, uncaught by the calling verb or Program.Main. That killed the whole hashing phase, discarding the hashes every other thread had already produced. Catch UnauthorizedAccessException alongside IOException, matching Deduplicator.StillMatchesGroup, which already catches both for the equivalent re-hash before deletion. The shared report path moves into a helper so the two branches cannot drift. The test arranges the denial with a directory standing in for a file, which File.OpenRead rejects with UnauthorizedAccessException on every platform for every user. Denying permission on a real file proves nothing under a privileged process, which reads it regardless. Fixes #126 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NPXBwr1rm8hybVmmWhx7wd
|
CI: the one red check is not this PR's
Everything that actually exercises the code is green: Test on ubuntu-latest, windows-latest and macos-latest, plus SonarCloud, CodeQL, Two further points confirming it is environmental:
I have not re-run it: Generated by Claude Code |



Fixes #126
The problem
FileHasher.HashFilescaught onlyIOException, andUnauthorizedAccessExceptiondoes not derive from it. Because hashing runs underParallel.ForEach, a single permission-denied file escaped the delegate and surfaced as anAggregateExceptionout ofHashFiles— uncaught by the calling verb orProgram.Main— killing the entire hashing phase and discarding the hashes every other thread had already produced.Deduplicator.StillMatchesGroupalready catches both types for the equivalent re-hash before deletion, so the two read paths disagreed about the same failure.The fix
A
catch (UnauthorizedAccessException ex)branch alongside theIOExceptionone. The shared reporting body moved into a smallReportSkippedhelper so the two branches cannot drift apart later.Testing
HashingSkipsAnUnreadableFileAndStillHashesTheResthashes three paths, one of them unreadable, and asserts the other two are still hashed and the unreadable one is simply absent.The denial is arranged with a directory standing in for a file, which
File.OpenReadrejects withUnauthorizedAccessExceptionon every platform for every user. This is deliberate:chmod 000on a real file proves nothing under a privileged process, and the test would pass whether or not the exception were handled — a false green on CI runners and in root containers.Verified the test catches the bug by running it against the unfixed code first:
That trace is also direct confirmation of the root cause: the inner exception is an
IOException, but the one thrown is theUnauthorizedAccessExceptionwrapping it, which the oldcatchcould never match.With the fix applied the test passes, and the full suite is green — 45 passed, 1 skipped, 0 failed (45 passing, up from 44).
🤖 Generated with Claude Code
https://claude.ai/code/session_01NPXBwr1rm8hybVmmWhx7wd
Generated by Claude Code