Skip to content

fix: catch UnauthorizedAccessException when deleting a duplicate [patch] - #117

Merged
matt-edmondson merged 2 commits into
mainfrom
claude/nice-davinci-p9kb6v
Sep 15, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
claude/nice-davinci-p9kb6v

Conversation

@matt-edmondson

@matt-edmondson matt-edmondson commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #113

What was wrong

File.Delete throws UnauthorizedAccessException — not IOException — when the target is read-only or the process lacks permission to unlink it. Deduplicator.DeleteDuplicates only caught IOException, and nothing further up the chain (Deduplicate.Run, BaseVerb<T>.Run, Program.Main) caught it either.

So one protected duplicate ended a destructive run partway through: files already deleted stayed deleted, every group after it went unprocessed, and the user saw neither the Deleted {n} file(s). summary nor any report of what had happened — a silent, inconsistent partial deletion.

The change

DeleteDuplicates now records the refusal against that one file and carries on, exactly as it already did for IOException. The production diff is purely additive: one new catch block. Its body repeats the IOException body verbatim, the way the two catch blocks in StillMatchesGroup already do — an earlier revision folded both into a shared helper, but that rewrote the untested, pre-existing IOException body and pulled its lines into SonarCloud's new-code coverage measure, failing the quality gate at 75% for a branch this PR did not introduce.

Test

ACopyThatCannotBeDeletedIsReportedAndTheRunCarriesOn stages two duplicate groups, one holding a copy that cannot be removed, and asserts the refusal lands in DeduplicationResult.Errors while the other group is still deduplicated.

Making a file undeletable is platform-specific, so DeletionBlock stages it either way and restores the permissions on disposal:

  • Windows refuses to unlink a file carrying FileAttributes.ReadOnly.
  • Unix ignores that bit when deleting — unlink is governed by write permission on the containing directory — so the directory is what gets write-protected.

A process running as root deletes through a write-protected directory regardless, so DeletionBlock spends a throwaway probe file to measure whether the block actually holds rather than guessing from platform and uid. If it does not, the test reports inconclusive instead of passing vacuously. CI runners are unprivileged, so it runs for real there — SonarCloud's line data on the first push confirms the new catch was executed in CI.

Verified by reverting the catch and re-running as an unprivileged user: the test fails with the UnauthorizedAccessException escaping DeleteDuplicates at Deduplicator.cs:74. With the fix, the full suite is 27/27 green (0 skipped) unprivileged, and Release builds with 0 warnings.

Not in this change

The triage on #113 suggested also adding a top-level handler in Program.Main, and noted #112 and #114 as the other two defects in the same confirm-and-delete flow. Those are left out to keep this PR to the one defect and its regression test.

🤖 Generated with Claude Code

https://claude.ai/code/session_011LU3aHGPGmAeEEVUGDiTK7

matt-edmondson and others added 2 commits September 15, 2026 06:26
File.Delete throws UnauthorizedAccessException -- not IOException -- when the
target is read-only or the process lacks permission to unlink it. Nothing in
the chain up to Program.Main caught it, so one protected duplicate ended a
destructive run partway through: files already deleted stayed deleted, the
remaining groups were never processed, and the user saw neither the summary
nor any report of what had happened.

The refusal is now recorded against that one file and the run carries on, the
same way an IOException already did.

Fixes #113

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LU3aHGPGmAeEEVUGDiTK7
Folding the two identical catch bodies into a shared helper rewrote the
IOException block, which pulled its never-covered lines into SonarCloud's
new-code measure and failed the quality gate at 75%. That branch has no test
and predates this change, so the tidy-up is not worth dragging it in.

The two bodies now repeat, the way the two catch blocks in StillMatchesGroup
already do, and the production diff is purely the new UnauthorizedAccessException
block -- every line of which the new test executes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LU3aHGPGmAeEEVUGDiTK7
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 2e5f38b into main Sep 15, 2026
13 checks passed
@matt-edmondson
matt-edmondson deleted the claude/nice-davinci-p9kb6v branch September 15, 2026 08: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.

UnauthorizedAccessException during deletion is not caught, crashing the process mid-run with an unreported partial result

1 participant