From b4a6b88532bce60be69cda44acb56cd3ed2cdfa9 Mon Sep 17 00:00:00 2001 From: James Bronder <36022278+jbronder@users.noreply.github.com> Date: Tue, 28 Jan 2025 12:57:35 -0800 Subject: [PATCH 1/4] feat(fs/unstable): add import references for `link` --- _tools/node_test_runner/run_test.mjs | 1 + fs/deno.json | 1 + 2 files changed, 2 insertions(+) diff --git a/_tools/node_test_runner/run_test.mjs b/_tools/node_test_runner/run_test.mjs index 4d1643626236..609b9052df9b 100644 --- a/_tools/node_test_runner/run_test.mjs +++ b/_tools/node_test_runner/run_test.mjs @@ -49,6 +49,7 @@ import "../../collections/union_test.ts"; import "../../collections/unzip_test.ts"; import "../../collections/without_all_test.ts"; import "../../collections/zip_test.ts"; +import "../../fs/unstable_link_test.ts"; import "../../fs/unstable_read_dir_test.ts"; import "../../fs/unstable_real_path_test.ts"; import "../../fs/unstable_stat_test.ts"; diff --git a/fs/deno.json b/fs/deno.json index 7b252ca87dc6..78b4db0e0b0a 100644 --- a/fs/deno.json +++ b/fs/deno.json @@ -14,6 +14,7 @@ "./expand-glob": "./expand_glob.ts", "./move": "./move.ts", "./unstable-chmod": "./unstable_chmod.ts", + "./unstable-link": "./unstable_link.ts", "./unstable-lstat": "./unstable_lstat.ts", "./unstable-read-dir": "./unstable_read_dir.ts", "./unstable-real-path": "./unstable_real_path.ts", From 8caca66fc7ccb5fb4e9bd8cf4d4f32bf6f70be30 Mon Sep 17 00:00:00 2001 From: James Bronder <36022278+jbronder@users.noreply.github.com> Date: Tue, 28 Jan 2025 12:58:56 -0800 Subject: [PATCH 2/4] feat(fs/unstable): add link functions and tests --- fs/unstable_link.ts | 60 +++++++++++++++ fs/unstable_link_test.ts | 153 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 fs/unstable_link.ts create mode 100644 fs/unstable_link_test.ts diff --git a/fs/unstable_link.ts b/fs/unstable_link.ts new file mode 100644 index 000000000000..743f0fa67199 --- /dev/null +++ b/fs/unstable_link.ts @@ -0,0 +1,60 @@ +// Copyright 2018-2025 the Deno authors. MIT license. + +import { getNodeFs, isDeno } from "./_utils.ts"; +import { mapError } from "./_map_error.ts"; + +/** + * Creates `newpath` as a hard link to `oldpath`. + * + * Requires `allow-read` and `allow-write` permissions. + * + * @example Usage + * ```ts ignore + * import { link } from "@std/fs/unstable-link"; + * await link("old/name", "new/name"); + * ``` + * + * @tags allow-read, allow-write + * + * @param oldpath The path of the resource pointed by the hard link. + * @param newpath The path of the hard link. + */ +export async function link(oldpath: string, newpath: string): Promise { + if (isDeno) { + await Deno.link(oldpath, newpath); + } else { + try { + await getNodeFs().promises.link(oldpath, newpath); + } catch (error) { + throw mapError(error); + } + } +} + +/** + * Synchonously creates `newpath` as a hard link to `oldpath`. + * + * Requires `allow-read` and `allow-write` permissions. + * + * @example Usage + * ```ts ignore + * import { linkSync } from "@std/fs/unstable-link"; + * linkSync("old/name", "new/name"); + * ``` + * + * @tags allow-read, allow-write + * + * @param oldpath The path of the resource pointed by the hard link. + * @param newpath The path of the hard link. + */ +export function linkSync(oldpath: string, newpath: string): void { + if (isDeno) { + Deno.linkSync(oldpath, newpath); + } else { + try { + getNodeFs().linkSync(oldpath, newpath); + } catch (error) { + throw mapError(error); + } + } +} diff --git a/fs/unstable_link_test.ts b/fs/unstable_link_test.ts new file mode 100644 index 000000000000..e752c678997c --- /dev/null +++ b/fs/unstable_link_test.ts @@ -0,0 +1,153 @@ +// Copyright 2018-2025 the Deno authors. MIT license. + +import { + assert, + assertEquals, + assertExists, + assertRejects, + assertThrows, +} from "@std/assert"; +import { link, linkSync } from "./unstable_link.ts"; +import { AlreadyExists, NotFound } from "./unstable_errors.js"; +import { mkdtemp, open, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { + closeSync, + mkdtempSync, + openSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +Deno.test("link() creates a hard link to a file and mutate through hard link", async () => { + const tempDirPath = await mkdtemp(resolve(tmpdir(), "link_")); + const testFile = join(tempDirPath, "testFile.txt"); + const linkFile = join(tempDirPath, "testFile.txt.hardlink"); + + const helloWrite = "Hello"; + const testFh = await open(testFile, "w"); + await testFh.writeFile(helloWrite); + await testFh.close(); + + // A single file implicitly has 1 hard link to an inode. + let testFileStat = await stat(testFile); + assertExists(testFileStat.nlink, "Hard link count is null"); + assert(testFileStat.nlink === 1); + + // Make another hard link with `link` to the same inode. + await link(testFile, linkFile); + testFileStat = await stat(testFile); + assertExists(testFileStat.nlink, "Hard link count is null"); + assert(testFileStat.nlink === 2); + + // Read test file content through the hard link. + const testLinkFh = await open(linkFile, "r+"); + const helloRead = await testLinkFh.readFile({ encoding: "utf8" }); + assertEquals(helloRead, helloWrite); + await testLinkFh.close(); + + // Overwrite file content through hard link and read through testFile. + const stdWrite = "Standard Library"; + await writeFile(linkFile, stdWrite); + const stdRead = await readFile(testFile, { encoding: "utf8" }); + assertEquals(stdRead, stdWrite); + + // Remove testFile, count links, and check hard link properties. + await rm(testFile); + const linkFileStat = await stat(linkFile); + assertExists(linkFileStat.nlink, "Hard link count is null"); + assert(linkFileStat.nlink === 1); + assert(linkFileStat.isFile()); + assert(!linkFileStat.isSymbolicLink()); + + await rm(tempDirPath, { recursive: true, force: true }); +}); + +Deno.test("link() rejects with AlreadyExists when hard linking with an existing path", async () => { + const tempDirPath = await mkdtemp(resolve(tmpdir(), "link_")); + const testFile = join(tempDirPath, "testFile.txt"); + const anotherFile = join(tempDirPath, "anotherFile.txt"); + + const testFh = await open(testFile, "w"); + await testFh.close(); + const anotherFh = await open(anotherFile, "w"); + await anotherFh.close(); + + assertRejects(async () => { + await link(testFile, anotherFile); + }, AlreadyExists); + + await rm(tempDirPath, { recursive: true, force: true }); +}); + +Deno.test("link() rejects with NotFound with a non-existent file", async () => { + await assertRejects(async () => { + await link("non-existent-file.txt", "non-existent-hard-link"); + }, NotFound); +}); + +Deno.test("linkSync() creates a hard link to a file and mutate through hard link", () => { + const tempDirPath = mkdtempSync(resolve(tmpdir(), "linkSync_")); + const testFile = join(tempDirPath, "testFile.txt"); + const linkFile = join(tempDirPath, "testFile.txt.hardlink"); + + const helloWrite = "Hello"; + writeFileSync(testFile, helloWrite); + + // A single file implicitly has 1 hard link to an inode. + let testFileStat = statSync(testFile); + assertExists(testFileStat.nlink, "Hard link count is null"); + assert(testFileStat.nlink === 1); + + // Make another hard link with `link` to the same inode. + linkSync(testFile, linkFile); + testFileStat = statSync(testFile); + assertExists(testFileStat.nlink, "Hard link count is null"); + assert(testFileStat.nlink === 2); + + // Read test file content through the hard link. + const helloRead = readFileSync(linkFile, { encoding: "utf8" }); + assertEquals(helloRead, helloWrite); + + // Overwrite file content through hard link and read through testFile. + const stdWrite = "Standard Library"; + writeFileSync(linkFile, stdWrite); + const stdRead = readFileSync(testFile, { encoding: "utf8" }); + assertEquals(stdRead, stdWrite); + + // Remove testFile, count links, and check hard link properties. + rmSync(testFile); + const linkFileStat = statSync(linkFile); + assertExists(linkFileStat.nlink, "Hard link count is null"); + assert(linkFileStat.nlink === 1); + assert(linkFileStat.isFile()); + assert(!linkFileStat.isSymbolicLink()); + + rmSync(tempDirPath, { recursive: true, force: true }); +}); + +Deno.test("linkSync() throws with AlreadyExists when hard linking with an existing path", () => { + const tempDirPath = mkdtempSync(resolve(tmpdir(), "link_")); + const testFile = join(tempDirPath, "testFile.txt"); + const anotherFile = join(tempDirPath, "anotherFile.txt"); + + const testFd = openSync(testFile, "w"); + closeSync(testFd); + const anotherFd = openSync(anotherFile, "w"); + closeSync(anotherFd); + + assertThrows(() => { + linkSync(testFile, anotherFile); + }, AlreadyExists); + + rmSync(tempDirPath, { recursive: true, force: true }); +}); + +Deno.test("linkSync() throws with NotFound with a non-existent file", () => { + assertThrows(() => { + linkSync("non-existent-file.txt", "non-existent-hard-link"); + }, NotFound); +}); From 4630c1c624e7734ae645a7042b92121bc0331a9e Mon Sep 17 00:00:00 2001 From: James Bronder <36022278+jbronder@users.noreply.github.com> Date: Tue, 28 Jan 2025 13:50:25 -0800 Subject: [PATCH 3/4] fix: cleanup typo --- fs/unstable_link.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fs/unstable_link.ts b/fs/unstable_link.ts index 743f0fa67199..d9e599a2726d 100644 --- a/fs/unstable_link.ts +++ b/fs/unstable_link.ts @@ -32,7 +32,7 @@ export async function link(oldpath: string, newpath: string): Promise { } /** - * Synchonously creates `newpath` as a hard link to `oldpath`. + * Synchronously creates `newpath` as a hard link to `oldpath`. * * Requires `allow-read` and `allow-write` permissions. * From b991d31f19522eedae8696cb71be1c26d464b723 Mon Sep 17 00:00:00 2001 From: James Bronder <36022278+jbronder@users.noreply.github.com> Date: Tue, 28 Jan 2025 13:51:14 -0800 Subject: [PATCH 4/4] fix: count hardlinks for *nix OSes and address writeFile call --- fs/unstable_link_test.ts | 72 +++++++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/fs/unstable_link_test.ts b/fs/unstable_link_test.ts index e752c678997c..ffbd9d3b0b17 100644 --- a/fs/unstable_link_test.ts +++ b/fs/unstable_link_test.ts @@ -19,7 +19,7 @@ import { statSync, writeFileSync, } from "node:fs"; -import { tmpdir } from "node:os"; +import { platform, tmpdir } from "node:os"; import { join, resolve } from "node:path"; Deno.test("link() creates a hard link to a file and mutate through hard link", async () => { @@ -28,26 +28,28 @@ Deno.test("link() creates a hard link to a file and mutate through hard link", a const linkFile = join(tempDirPath, "testFile.txt.hardlink"); const helloWrite = "Hello"; - const testFh = await open(testFile, "w"); - await testFh.writeFile(helloWrite); - await testFh.close(); + await writeFile(testFile, helloWrite); - // A single file implicitly has 1 hard link to an inode. - let testFileStat = await stat(testFile); - assertExists(testFileStat.nlink, "Hard link count is null"); - assert(testFileStat.nlink === 1); + // Linux & Mac: A single file implicitly has 1 hard link count to an inode. + if (platform() !== "win32") { + const testFileStat = await stat(testFile); + assertExists(testFileStat.nlink, "Hard link count is null"); + assert(testFileStat.nlink === 1); + } - // Make another hard link with `link` to the same inode. + // Make another hard link with `link` to the same file. (Linux & Mac - inode). await link(testFile, linkFile); - testFileStat = await stat(testFile); - assertExists(testFileStat.nlink, "Hard link count is null"); - assert(testFileStat.nlink === 2); + + // Linux & Mac: Count hard links. + if (platform() !== "win32") { + const testFileStat = await stat(testFile); + assertExists(testFileStat.nlink, "Hard link count is null"); + assert(testFileStat.nlink === 2); + } // Read test file content through the hard link. - const testLinkFh = await open(linkFile, "r+"); - const helloRead = await testLinkFh.readFile({ encoding: "utf8" }); + const helloRead = await readFile(linkFile, { encoding: "utf8" }); assertEquals(helloRead, helloWrite); - await testLinkFh.close(); // Overwrite file content through hard link and read through testFile. const stdWrite = "Standard Library"; @@ -57,12 +59,17 @@ Deno.test("link() creates a hard link to a file and mutate through hard link", a // Remove testFile, count links, and check hard link properties. await rm(testFile); + const linkFileStat = await stat(linkFile); - assertExists(linkFileStat.nlink, "Hard link count is null"); - assert(linkFileStat.nlink === 1); assert(linkFileStat.isFile()); assert(!linkFileStat.isSymbolicLink()); + // Linux & Mac: Count hard links. + if (platform() !== "win32") { + assertExists(linkFileStat.nlink, "Hard link count is null"); + assert(linkFileStat.nlink === 1); + } + await rm(tempDirPath, { recursive: true, force: true }); }); @@ -76,7 +83,7 @@ Deno.test("link() rejects with AlreadyExists when hard linking with an existing const anotherFh = await open(anotherFile, "w"); await anotherFh.close(); - assertRejects(async () => { + await assertRejects(async () => { await link(testFile, anotherFile); }, AlreadyExists); @@ -97,16 +104,22 @@ Deno.test("linkSync() creates a hard link to a file and mutate through hard link const helloWrite = "Hello"; writeFileSync(testFile, helloWrite); - // A single file implicitly has 1 hard link to an inode. - let testFileStat = statSync(testFile); - assertExists(testFileStat.nlink, "Hard link count is null"); - assert(testFileStat.nlink === 1); + // Linux & Mac: A single file implicitly has 1 hard link to an inode. + if (platform() !== "win32") { + const testFileStat = statSync(testFile); + assertExists(testFileStat.nlink, "Hard link count is null"); + assert(testFileStat.nlink === 1); + } // Make another hard link with `link` to the same inode. linkSync(testFile, linkFile); - testFileStat = statSync(testFile); - assertExists(testFileStat.nlink, "Hard link count is null"); - assert(testFileStat.nlink === 2); + + // Linux & Mac: Count hard links. + if (platform() !== "win32") { + const testFileStat = statSync(testFile); + assertExists(testFileStat.nlink, "Hard link count is null"); + assert(testFileStat.nlink === 2); + } // Read test file content through the hard link. const helloRead = readFileSync(linkFile, { encoding: "utf8" }); @@ -120,12 +133,17 @@ Deno.test("linkSync() creates a hard link to a file and mutate through hard link // Remove testFile, count links, and check hard link properties. rmSync(testFile); + const linkFileStat = statSync(linkFile); - assertExists(linkFileStat.nlink, "Hard link count is null"); - assert(linkFileStat.nlink === 1); assert(linkFileStat.isFile()); assert(!linkFileStat.isSymbolicLink()); + // Linux & Mac: Count hard links. + if (platform() !== "win32") { + assertExists(linkFileStat.nlink, "Hard link count is null"); + assert(linkFileStat.nlink === 1); + } + rmSync(tempDirPath, { recursive: true, force: true }); });