fix(fs): exists rethrows Deno.errors.PermissionDenied when existence is indeterminate#7170
fix(fs): exists rethrows Deno.errors.PermissionDenied when existence is indeterminate#7170LeSingh1 wants to merge 1 commit into
Conversation
exists/existsSync returned `true` for any path that produced a PermissionDenied at stat() time (with --allow-read granted), on the assumption that the OS error meant "the item is there, just not readable". The far more common cause is that the parent directory isn't traversable, in which case we can't determine existence and the "true" answer silently misleads callers (denoland#6528). Rethrow Deno.errors.PermissionDenied for the no-isReadable path so callers can decide how to handle the indeterminate case. Keep the old behavior for `isReadable: true`: that question always has a defensible answer (the OS just told us "can't read" → false). This preserves the existing `exists() returns true for an existing dir symlink` test that exercises the isReadable branch with chmod 000 on the parent. Adds Unix-gated regression tests for both exists() and existsSync() covering the chmod-000-parent case and confirming `isReadable: true` still returns false. Fixes denoland#6528
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #7170 +/- ##
==========================================
- Coverage 94.57% 94.57% -0.01%
==========================================
Files 636 636
Lines 52142 52146 +4
Branches 9401 9403 +2
==========================================
+ Hits 49315 49318 +3
Misses 2249 2249
- Partials 578 579 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
bartlomieju
left a comment
There was a problem hiding this comment.
Overview
Fixes #6528. When Deno.stat throws PermissionDenied and --allow-read is granted, exists()/existsSync() previously returned true (assuming "item is there, just not readable"). This PR instead rethrows Deno.errors.PermissionDenied for the default case, since the more common cause is a non-traversable parent directory where existence is genuinely indeterminate. The { isReadable: true } branch keeps returning false. Two Unix-gated regression tests are added.
The reasoning is sound and the diagnosis of the original bug is correct — a stat() PermissionDenied almost always means a parent directory lacks the execute/search bit, in which case true was an actively misleading answer.
Main concern: this is an undocumented contract change on a stable API
The JSDoc for both functions still says @returns ... true if the path exists, false otherwise with no mention that the function can throw PermissionDenied. With this change, a call that previously always returned a boolean (under --allow-read) can now throw. That needs to be reflected in the public docs:
- Add a
@throws {Deno.errors.PermissionDenied}tag to bothexistsandexistsSyncdescribing the indeterminate-existence case. - Consider a short note in the description, since callers relying on the old
truewill now see an exception.
Separately — this is a stable module, so the maintainers should consciously sign off on the behavior change (vs. the old buggy-but-non-throwing behavior). The fix is correct in principle; just flagging that some existing callers swallow the old true and will now get an exception instead.
Minor
- The
{ isReadable: true }branch returningfalsein the same indeterminate scenario is a slight philosophical inconsistency (if we can'tstat, we can't actually know it's unreadable either). Keeping it for backward-compat with the existing symlink test is a defensible call — worth a one-line code comment noting this is intentional. - Tests could be tightened with
assertRejects/assertThrows(inline).
Overall the change is well-reasoned and well-tested; the documentation update is the one thing I'd consider blocking.
| // genuinely can't tell whether the path exists — guessing "true" | ||
| // silently misleads callers (see #6528). Re-throw so the caller | ||
| // can decide how to handle the indeterminate case. | ||
| if (options?.isReadable) return false; |
There was a problem hiding this comment.
The { isReadable: true } path confidently returns false for the exact scenario the default path now treats as indeterminate (parent not traversable → can't even stat, so we can't truly know it's unreadable). Keeping it for backward-compat with the existing exists() returns true for an existing dir symlink test is reasonable, but a one-liner noting this asymmetry is intentional would help future readers.
| let caught: unknown; | ||
| try { | ||
| await exists(innerPath); | ||
| } catch (e) { | ||
| caught = e; | ||
| } | ||
| assert(caught instanceof Deno.errors.PermissionDenied); |
There was a problem hiding this comment.
Consider assertRejects here (and assertThrows in the sync test) instead of the manual try/catch + instanceof assert — it's the std convention and reads cleaner:
await assertRejects(
() => exists(innerPath),
Deno.errors.PermissionDenied,
);They'd need adding to the @std/assert import at the top of the file.
Fixes #6528.
existsandexistsSyncreturnedtruefor any path that produced aPermissionDeniedatstat()time (with--allow-readgranted), on the assumption that the OS error meant the item is there, just not readable. The far more common cause is that the parent directory isn't traversable, in which case we genuinely cannot determine existence and thetrueanswer silently misleads the caller.Repro from the issue:
After the patch,
exists("/root/foo")rethrowsDeno.errors.PermissionDeniedand the caller can pick the right policy for their context.exists(path, { isReadable: true })is unchanged. That question has a defensible answer (the OS just told us "can't read" →false), and the existingexists() returns true for an existing dir symlinktest infs/exists_test.tsrelies on it (it chmod-0o000s the parent and expectsfalse).Two new Unix-gated regression tests cover both
exists()andexistsSync(), asserting:Deno.errors.PermissionDeniedis rethrown when the parent is unreadable{ isReadable: true }still returnsfalsein the same scenarioThis is a small but real behavior change for callers that today swallow a misleading
true. Net diff is small and is documented inline at both call sites.