diff --git a/_tools/node_test_runner/run_test.mjs b/_tools/node_test_runner/run_test.mjs index fc447f31d0fe..a3ad0260b9b6 100644 --- a/_tools/node_test_runner/run_test.mjs +++ b/_tools/node_test_runner/run_test.mjs @@ -52,6 +52,7 @@ import "../../collections/zip_test.ts"; import "../../fs/unstable_read_dir_test.ts"; import "../../fs/unstable_stat_test.ts"; import "../../fs/unstable_lstat_test.ts"; +import "../../fs/unstable_chmod_test.ts"; for (const testDef of testDefinitions) { test(testDef.name, testDef.fn); diff --git a/fs/deno.json b/fs/deno.json index 485428eba797..809654b85731 100644 --- a/fs/deno.json +++ b/fs/deno.json @@ -13,6 +13,7 @@ "./exists": "./exists.ts", "./expand-glob": "./expand_glob.ts", "./move": "./move.ts", + "./unstable-chmod": "./unstable_chmod.ts", "./unstable-lstat": "./unstable_lstat.ts", "./unstable-read-dir": "./unstable_read_dir.ts", "./unstable-stat": "./unstable_stat.ts", diff --git a/fs/unstable_chmod.ts b/fs/unstable_chmod.ts new file mode 100644 index 000000000000..43a95a7b4897 --- /dev/null +++ b/fs/unstable_chmod.ts @@ -0,0 +1,88 @@ +// Copyright 2018-2025 the Deno authors. MIT license. + +import { getNodeFs, isDeno } from "./_utils.ts"; +import { mapError } from "./_map_error.ts"; + +/** + * Changes the permission of a specific file/directory of specified path. + * Ignores the process's umask. + * + * Requires `allow-write` permission. + * + * The mode is a sequence of 3 octal numbers. The first/left-most number + * specifies the permissions for the owner. The second number specifies the + * permissions for the group. The last/right-most number specifies the + * permissions for others. For example, with a mode of 0o764, the owner (7) + * can read/write/execute, the group (6) can read/write and everyone else (4) + * can read only. + * + * | Number | Description | + * | ------ | ----------- | + * | 7 | read, write, and execute | + * | 6 | read and write | + * | 5 | read and execute | + * | 4 | read only | + * | 3 | write and execute | + * | 2 | write only | + * | 1 | execute only | + * | 0 | no permission | + * + * NOTE: This API currently throws on Windows. + * + * @example Usage + * ```ts ignore + * import { chmod } from "@std/fs/unstable-chmod"; + * + * await chmod("README.md", 0o444); + * ``` + * + * @tags allow-write + * + * @param path The path to the file or directory. + * @param mode A sequence of 3 octal numbers representing file permissions. + */ +export async function chmod(path: string | URL, mode: number) { + if (isDeno) { + await Deno.chmod(path, mode); + } else { + try { + await getNodeFs().promises.chmod(path, mode); + } catch (error) { + throw mapError(error); + } + } +} + +/** + * Synchronously changes the permission of a specific file/directory of + * specified path. Ignores the process's umask. + * + * Requires `allow-write` permission. + * + * For a full description, see {@linkcode chmod}. + * + * NOTE: This API currently throws on Windows. + * + * @example Usage + * ```ts ignore + * import { chmodSync } from "@std/fs/unstable-chmod"; + * + * chmodSync("README.md", 0o666); + * ``` + * + * @tags allow-write + * + * @param path The path to the file or directory. + * @param mode A sequence of 3 octal numbers representing permissions. See {@linkcode chmod}. + */ +export function chmodSync(path: string | URL, mode: number) { + if (isDeno) { + Deno.chmodSync(path, mode); + } else { + try { + getNodeFs().chmodSync(path, mode); + } catch (error) { + throw mapError(error); + } + } +} diff --git a/fs/unstable_chmod_test.ts b/fs/unstable_chmod_test.ts new file mode 100644 index 000000000000..0b648bac50a7 --- /dev/null +++ b/fs/unstable_chmod_test.ts @@ -0,0 +1,190 @@ +// Copyright 2018-2025 the Deno authors. MIT license. + +import { + assertEquals, + assertExists, + assertRejects, + assertThrows, +} from "@std/assert"; +import { chmod, chmodSync } from "./unstable_chmod.ts"; +import { NotFound } from "./unstable_errors.js"; +import { platform, tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { mkdir, mkdtemp, open, rm, stat, symlink } from "node:fs/promises"; +import { + closeSync, + mkdirSync, + mkdtempSync, + openSync, + rmSync, + statSync, + symlinkSync, +} from "node:fs"; + +Deno.test({ + name: "chmod() sets read only permission bits on regular files", + ignore: platform() === "win32", + fn: async () => { + const tempDirPath = await mkdtemp(resolve(tmpdir(), "chmod_")); + const testFile = join(tempDirPath, "chmod_file.txt"); + const tempFh = await open(testFile, "w"); + + // Check initial testFile permissions are 0o644 (-rw-r--r--). + const fileStatBefore = await stat(testFile); + assertExists(fileStatBefore.mode, "mode property is null"); + assertEquals(fileStatBefore.mode & 0o644, 0o644); + + // Set testFile permission bits to read only, 0o444 (-r--r--r--). + await chmod(testFile, 0o444); + const fileStatAfter = await stat(testFile); + assertExists(fileStatAfter.mode, "mode property is null"); + assertEquals(fileStatAfter.mode & 0o444, 0o444); + + await tempFh.close(); + await rm(tempDirPath, { recursive: true, force: true }); + }, +}); + +Deno.test({ + name: "chmod() sets read only permission bits on a directory", + ignore: platform() === "win32", + fn: async () => { + const tempDirPath = await mkdtemp(resolve(tmpdir(), "chmod_")); + const testDir = resolve(tempDirPath, "testDir"); + await mkdir(testDir); + + // Check initial testDir permissions are 0o755 (drwxr-xr-x). + const dirStatBefore = await stat(testDir); + assertExists(dirStatBefore.mode, "mode property is null"); + assertEquals(dirStatBefore.mode & 0o755, 0o755); + + // Set testDir permission bits to read only to 0o444 (dr--r--r--). + await chmod(testDir, 0o444); + const dirStatAfter = await stat(testDir); + assertExists(dirStatAfter.mode, "mode property is null"); + assertEquals(dirStatAfter.mode & 0o444, 0o444); + + await rm(tempDirPath, { recursive: true, force: true }); + }, +}); + +Deno.test({ + name: "chmod() sets write only permission bits of regular file via symlink", + ignore: platform() === "win32", + fn: async () => { + const tempDirPath = await mkdtemp(resolve(tmpdir(), "chmod_")); + const testFile = resolve(tempDirPath, "chmod_file.txt"); + const testSymlink = resolve(tempDirPath, "chmod_file.txt.link"); + + const tempFh = await open(testFile, "w"); + await symlink(testFile, testSymlink); + + // Check initial testFile permission bits are 0o644 (-rw-r-xr-x) reading through testSymlink. + const symlinkStatBefore = await stat(testSymlink); + assertExists(symlinkStatBefore.mode, "mode property via symlink is null"); + assertEquals(symlinkStatBefore.mode & 0o644, 0o644); + + // Set write only permission bits of testFile through testSymlink to 0o222 (--w--w--w-). + await chmod(testSymlink, 0o222); + const symlinkStatAfter = await stat(testSymlink); + assertExists(symlinkStatAfter.mode, "mode property via symlink is null"); + const fileStatAfter = await stat(testFile); + assertExists(fileStatAfter.mode, "mode property via file is null"); + + // Check if both regular file mode and the mode read through symlink are both write only. + assertEquals(symlinkStatAfter.mode, fileStatAfter.mode); + + await tempFh.close(); + await rm(tempDirPath, { recursive: true, force: true }); + }, +}); + +Deno.test("chmod() rejects with NotFound for a non-existent file", async () => { + await assertRejects(async () => { + await chmod("non_existent_file.txt", 0o644); + }, NotFound); +}); + +Deno.test({ + name: "chmodSync() sets read-only permission bits on regular files", + ignore: platform() === "win32", + fn: () => { + const tempDirPath = mkdtempSync(resolve(tmpdir(), "chmodSync_")); + const testFile = resolve(tempDirPath, "chmod_file.txt"); + const tempFd = openSync(testFile, "w"); + + // Check initial testFile permissions are 0o644 (-rw-r--r--). + const fileStatBefore = statSync(testFile); + assertExists(fileStatBefore.mode, "mode property is null"); + assertEquals(fileStatBefore.mode & 0o644, 0o644); + + // Set testFile permission bits to read only, 0o444 (-r--r--r--). + chmodSync(testFile, 0o444); + const fileStatAfter = statSync(testFile); + assertExists(fileStatAfter.mode, "mode property is null"); + assertEquals(fileStatAfter.mode & 0o444, 0o444); + + closeSync(tempFd); + rmSync(tempDirPath, { recursive: true, force: true }); + }, +}); + +Deno.test({ + name: "chmodSync() sets read-only permissions bits on directories", + ignore: platform() === "win32", + fn: () => { + const tempDirPath = mkdtempSync(resolve(tmpdir(), "chmodSync_")); + const testDir = resolve(tempDirPath, "testDir"); + mkdirSync(testDir); + + // Check initial testDir permissions are 0o755 (drwxr-xr-x). + const dirStatBefore = statSync(testDir); + assertExists(dirStatBefore.mode, "mode property is null"); + assertEquals(dirStatBefore.mode & 0o755, 0o755); + + // Set testDir permission bits to read only to 0o444 (dr--r--r--). + chmodSync(testDir, 0o444); + const dirStatAfter = statSync(testDir); + assertExists(dirStatAfter.mode, "mode property is null"); + assertEquals(dirStatAfter.mode & 0o444, 0o444); + + rmSync(tempDirPath, { recursive: true, force: true }); + }, +}); + +Deno.test({ + name: "chmodSync() sets write only permission on a regular file via symlink", + ignore: platform() === "win32", + fn: () => { + const tempDirPath = mkdtempSync(resolve(tmpdir(), "chmodSync_")); + const testFile = resolve(tempDirPath, "chmod_file.txt"); + const testSymlink = resolve(tempDirPath, "chmod_file.txt.link"); + + const tempFd = openSync(testFile, "w"); + symlinkSync(testFile, testSymlink); + + // Check initial testFile permission bits are 0o644 (-rw-r-xr-x) reading through testSymlink. + const symlinkStatBefore = statSync(testSymlink); + assertExists(symlinkStatBefore.mode, "mode property via symlink is null"); + assertEquals(symlinkStatBefore.mode & 0o644, 0o644); + + // Set write only permission bits of testFile through testSymlink to 0o222 (--w--w--w-). + chmodSync(testSymlink, 0o222); + const symlinkStatAfter = statSync(testSymlink); + assertExists(symlinkStatAfter.mode, "mode property via symlink is null"); + const fileStatAfter = statSync(testFile); + assertExists(fileStatAfter.mode, "mode property via file is null"); + + // Check if both regular file mode and the mode read through symlink are both write only. + assertEquals(symlinkStatAfter.mode, fileStatAfter.mode); + + closeSync(tempFd); + rmSync(tempDirPath, { recursive: true, force: true }); + }, +}); + +Deno.test("chmodSync() throws with NotFound for a non-existent file", () => { + assertThrows(() => { + chmodSync("non_existent_file.txt", 0o644); + }, NotFound); +});