From 7d1e8dbda8e3a5c948e8c09521096ffd9d979147 Mon Sep 17 00:00:00 2001 From: LeSingh1 Date: Fri, 29 May 2026 17:24:51 -0700 Subject: [PATCH] fix(fs): exists rethrows PermissionDenied for indeterminate cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (#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 #6528 --- fs/exists.ts | 22 +++++++++++++---- fs/exists_test.ts | 61 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/fs/exists.ts b/fs/exists.ts index 2f416e466f85..ad645519519f 100644 --- a/fs/exists.ts +++ b/fs/exists.ts @@ -156,8 +156,15 @@ export async function exists( (await Deno.permissions.query({ name: "read", path })).state === "granted" ) { - // --allow-read not missing - return !options?.isReadable; // PermissionDenied was raised by file system, so the item exists, but can't be read + // --allow-read is not missing, so PermissionDenied came from the + // OS. If the caller specifically asked whether the path is + // readable, "no" is a defensible answer. Otherwise the usual + // cause is that the parent directory isn't traversable and we + // 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; + throw error; } } throw error; @@ -294,8 +301,15 @@ export function existsSync( if ( Deno.permissions.querySync({ name: "read", path }).state === "granted" ) { - // --allow-read not missing - return !options?.isReadable; // PermissionDenied was raised by file system, so the item exists, but can't be read + // --allow-read is not missing, so PermissionDenied came from the + // OS. If the caller specifically asked whether the path is + // readable, "no" is a defensible answer. Otherwise the usual + // cause is that the parent directory isn't traversable and we + // 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; + throw error; } } throw error; diff --git a/fs/exists_test.ts b/fs/exists_test.ts index f1114fffd1b7..4030d8fb638b 100644 --- a/fs/exists_test.ts +++ b/fs/exists_test.ts @@ -362,3 +362,64 @@ Deno.test("existsSync() returns false when both isDirectory and isFile sets true await Deno.remove(tempDirPath, { recursive: true }); } }); + +// https://github.com/denoland/std/issues/6528 — when --allow-read is granted +// but the OS denies access (typically because the parent directory isn't +// traversable), the previous behavior returned `true` and silently misled +// callers. Now we rethrow Deno.errors.PermissionDenied so callers can decide +// how to handle the indeterminate case. The `isReadable: true` branch is +// unchanged: that question has a definitive answer (not readable → false). +Deno.test( + { + name: + "exists() rethrows Deno.errors.PermissionDenied when the parent is inaccessible (#6528)", + ignore: Deno.build.os === "windows", + }, + async function () { + const tempDirPath = await Deno.makeTempDir(); + const innerPath = path.join(tempDirPath, "inner"); + try { + await Deno.chmod(tempDirPath, 0o000); + let caught: unknown; + try { + await exists(innerPath); + } catch (e) { + caught = e; + } + assert(caught instanceof Deno.errors.PermissionDenied); + + // isReadable:true still returns false — that question can be answered. + assertEquals(await exists(innerPath, { isReadable: true }), false); + } finally { + await Deno.chmod(tempDirPath, 0o755); + await Deno.remove(tempDirPath, { recursive: true }); + } + }, +); + +Deno.test( + { + name: + "existsSync() rethrows Deno.errors.PermissionDenied when the parent is inaccessible (#6528)", + ignore: Deno.build.os === "windows", + }, + function () { + const tempDirPath = Deno.makeTempDirSync(); + const innerPath = path.join(tempDirPath, "inner"); + try { + Deno.chmodSync(tempDirPath, 0o000); + let caught: unknown; + try { + existsSync(innerPath); + } catch (e) { + caught = e; + } + assert(caught instanceof Deno.errors.PermissionDenied); + + assertEquals(existsSync(innerPath, { isReadable: true }), false); + } finally { + Deno.chmodSync(tempDirPath, 0o755); + Deno.removeSync(tempDirPath, { recursive: true }); + } + }, +);