Skip to content

Commit

Permalink
feat(fs/unstable): add link and linkSync (#6369)
Browse files Browse the repository at this point in the history
  • Loading branch information
jbronder authored Jan 29, 2025
1 parent edc04c2 commit 730fd2e
Show file tree
Hide file tree
Showing 4 changed files with 233 additions and 0 deletions.
1 change: 1 addition & 0 deletions _tools/node_test_runner/run_test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions fs/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
60 changes: 60 additions & 0 deletions fs/unstable_link.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
if (isDeno) {
await Deno.link(oldpath, newpath);
} else {
try {
await getNodeFs().promises.link(oldpath, newpath);
} catch (error) {
throw mapError(error);
}
}
}

/**
* Synchronously 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);
}
}
}
171 changes: 171 additions & 0 deletions fs/unstable_link_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// 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 { 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 () => {
const tempDirPath = await mkdtemp(resolve(tmpdir(), "link_"));
const testFile = join(tempDirPath, "testFile.txt");
const linkFile = join(tempDirPath, "testFile.txt.hardlink");

const helloWrite = "Hello";
await writeFile(testFile, helloWrite);

// 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 file. (Linux & Mac - inode).
await link(testFile, linkFile);

// 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 helloRead = await readFile(linkFile, { encoding: "utf8" });
assertEquals(helloRead, helloWrite);

// 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);
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 });
});

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();

await 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);

// 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);

// 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" });
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);
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 });
});

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);
});

0 comments on commit 730fd2e

Please sign in to comment.