Skip to content

fix: skip a file the hashing pass cannot read instead of aborting [patch] - #129

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/hasher-skip-unauthorized
Sep 22, 2026
Merged

matt-edmondson merged 1 commit into
mainfrom
claude/hasher-skip-unauthorized

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #126

The problem

FileHasher.HashFiles caught only IOException, and UnauthorizedAccessException does not derive from it. Because hashing runs under Parallel.ForEach, a single permission-denied file escaped the delegate and surfaced as an AggregateException out of HashFiles — uncaught by the calling verb or Program.Main — killing the entire hashing phase and discarding the hashes every other thread had already produced.

Deduplicator.StillMatchesGroup already 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 the IOException one. The shared reporting body moved into a small ReportSkipped helper so the two branches cannot drift apart later.

Testing

HashingSkipsAnUnreadableFileAndStillHashesTheRest hashes 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.OpenRead rejects with UnauthorizedAccessException on every platform for every user. This is deliberate: chmod 000 on 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:

System.AggregateException: One or more errors occurred. (Access to the path '.../denied' is denied.)
 ---> System.UnauthorizedAccessException: Access to the path '.../denied' is denied.
 ---> System.IO.IOException: Permission denied
failed HashingSkipsAnUnreadableFileAndStillHashesTheRest

That trace is also direct confirmation of the root cause: the inner exception is an IOException, but the one thrown is the UnauthorizedAccessException wrapping it, which the old catch could 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

…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
@sonarqubecloud

Copy link
Copy Markdown

Copy link
Copy Markdown
Contributor Author

CI: the one red check is not this PR's

github-advanced-security is failing, and it is not caused by this change. The job log shows the Copilot autofind agent aborting on an account quota, not on anything in the diff:

errorType: 'quota',
statusCode: 402,
[cause]: [Error: You have exceeded your monthly quota (Request ID: 5803:3E7D11:582714:69A947:6AB1947B)]

Everything that actually exercises the code is green: Test on ubuntu-latest, windows-latest and macos-latest, plus SonarCloud, CodeQL, Analyze (csharp) and Analyze & Release.

Two further points confirming it is environmental:

I have not re-run it: rerun-failed-jobs returns 403 This workflow run cannot be retried for this workflow, and a re-run would not clear a quota error anyway. There is no fix to port into this PR — the remedy is on the account's Copilot quota, outside this repository. Flagging rather than working around it, since suppressing a security check is not something this PR should do.


Generated by Claude Code

@matt-edmondson
matt-edmondson merged commit 13ed475 into main Sep 22, 2026
11 of 12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/hasher-skip-unauthorized branch September 22, 2026 00:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hashing pass aborts entirely on a single permission-denied file instead of skipping it

2 participants