Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion packages/yarnpkg-fslib/sources/algorithms/copyPromise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,28 @@ async function copyFolder<P1 extends Path, P2 extends Path>(prelayout: Operation
return updated;
}

/**
* Determines whether a hard link error should trigger a copy fallback.
*
* - EXDEV: cross-device link (index and project on different volumes)
* - EMLINK: too many links (inode limit on Linux/macOS)
* - UNKNOWN + link syscall: NTFS hard link limit on Windows (1024 per file);
* libuv does not map ERROR_TOO_MANY_LINKS to EMLINK
* - EPERM + link syscall: insufficient privileges for hard links (e.g.
* Windows without Developer Mode enabled)
*/
function shouldFallbackToCopy(err: any): boolean {
if (err.code === `EXDEV`)
return true;
if (err.code === `EMLINK`)
return true;
if (err.code === `UNKNOWN` && err.syscall === `link`)
return true;
if (err.code === `EPERM` && err.syscall === `link`)
return true;
return false;
}

async function copyFileViaIndex<P1 extends Path, P2 extends Path>(prelayout: Operations, postlayout: Operations, destinationFs: FakeFS<P1>, destination: P1, destinationStat: Stats | null, sourceFs: FakeFS<P2>, source: P2, sourceStat: Stats, opts: CopyOptions<P1>, linkStrategy: HardlinkFromIndexStrategy<P1>) {
const sourceHash = await sourceFs.checksumFilePromise(source, {algorithm: `sha1`});

Expand Down Expand Up @@ -264,7 +286,19 @@ async function copyFileViaIndex<P1 extends Path, P2 extends Path>(prelayout: Ope
}

if (!destinationStat) {
await destinationFs.linkPromise(indexPath, destination);
try {
await destinationFs.linkPromise(indexPath, destination);
} catch (err) {
if (shouldFallbackToCopy(err)) {
const content = await destinationFs.readFilePromise(indexPath);
await destinationFs.writeFilePromise(destination, content);
if (sourceMode !== defaultMode) {
await destinationFs.chmodPromise(destination, sourceMode);
}
} else {
throw err;
}
}
}
});

Expand Down
192 changes: 192 additions & 0 deletions packages/yarnpkg-fslib/tests/copyPromise.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import {NodeFS} from '../sources/NodeFS';
import {xfs, ppath, Filename} from '../sources';
import {setupCopyIndex} from '../sources';

const nodeFs = new NodeFS();

function makeLinkError(code: string, syscall = `link`): NodeJS.ErrnoException {
const err: NodeJS.ErrnoException = new Error(`${code}: simulated link failure`);
err.code = code;
err.syscall = syscall;
return err;
}

describe(`copyPromise`, () => {
describe(`HardlinkFromIndex fallback`, () => {
async function setupIndexEnv() {
const tmpdir = await xfs.mktempPromise();
const indexPath = ppath.join(tmpdir, `index` as Filename);
await setupCopyIndex(nodeFs, {indexPath});

const sourceDir = ppath.join(tmpdir, `source` as Filename);
const destDir = ppath.join(tmpdir, `dest` as Filename);

await xfs.mkdirPromise(sourceDir);
await xfs.mkdirPromise(destDir);

const sourceFile = ppath.join(sourceDir, `file.txt` as Filename);
await xfs.writeFilePromise(sourceFile, `Hello World`);

return {tmpdir, indexPath, sourceDir, destDir, sourceFile};
}

it(`should hardlink from index under normal conditions`, async () => {
const {indexPath, sourceFile, destDir} = await setupIndexEnv();
const destFile = ppath.join(destDir, `file.txt` as Filename);

await nodeFs.copyPromise(destFile, sourceFile, {
linkStrategy: {type: `HardlinkFromIndex`, indexPath, autoRepair: true},
stableTime: true,
overwrite: true,
stableSort: true,
});

await expect(xfs.readFilePromise(destFile, `utf8`)).resolves.toBe(`Hello World`);

const destStat = await xfs.statPromise(destFile);
expect(destStat.nlink).toBeGreaterThan(1);
});

for (const errorCode of [`EXDEV`, `EMLINK`]) {
it(`should fall back to copy on ${errorCode}`, async () => {
const {indexPath, sourceFile, destDir} = await setupIndexEnv();
const destFile = ppath.join(destDir, `file.txt` as Filename);

const originalLink = nodeFs.linkPromise.bind(nodeFs);
let linkCallCount = 0;
const spy = jest.spyOn(nodeFs, `linkPromise`).mockImplementation(async (existingPath, newPath) => {
linkCallCount++;
// Allow the first link (temp → index), fail the second (index → destination)
if (linkCallCount <= 1)
return originalLink(existingPath, newPath);
throw makeLinkError(errorCode);
});

try {
await nodeFs.copyPromise(destFile, sourceFile, {
linkStrategy: {type: `HardlinkFromIndex`, indexPath, autoRepair: true},
stableTime: true,
overwrite: true,
stableSort: true,
});

await expect(xfs.readFilePromise(destFile, `utf8`)).resolves.toBe(`Hello World`);

// Destination should be a copy, not a hard link (nlink === 1)
const destStat = await xfs.statPromise(destFile);
expect(destStat.nlink).toBe(1);
} finally {
spy.mockRestore();
}
});
}

it(`should fall back to copy on UNKNOWN with link syscall (NTFS limit)`, async () => {
const {indexPath, sourceFile, destDir} = await setupIndexEnv();
const destFile = ppath.join(destDir, `file.txt` as Filename);

const originalLink = nodeFs.linkPromise.bind(nodeFs);
let linkCallCount = 0;
const spy = jest.spyOn(nodeFs, `linkPromise`).mockImplementation(async (existingPath, newPath) => {
linkCallCount++;
if (linkCallCount <= 1)
return originalLink(existingPath, newPath);
throw makeLinkError(`UNKNOWN`, `link`);
});

try {
await nodeFs.copyPromise(destFile, sourceFile, {
linkStrategy: {type: `HardlinkFromIndex`, indexPath, autoRepair: true},
stableTime: true,
overwrite: true,
stableSort: true,
});

await expect(xfs.readFilePromise(destFile, `utf8`)).resolves.toBe(`Hello World`);
} finally {
spy.mockRestore();
}
});

it(`should fall back to copy on EPERM with link syscall`, async () => {
const {indexPath, sourceFile, destDir} = await setupIndexEnv();
const destFile = ppath.join(destDir, `file.txt` as Filename);

const originalLink = nodeFs.linkPromise.bind(nodeFs);
let linkCallCount = 0;
const spy = jest.spyOn(nodeFs, `linkPromise`).mockImplementation(async (existingPath, newPath) => {
linkCallCount++;
if (linkCallCount <= 1)
return originalLink(existingPath, newPath);
throw makeLinkError(`EPERM`, `link`);
});

try {
await nodeFs.copyPromise(destFile, sourceFile, {
linkStrategy: {type: `HardlinkFromIndex`, indexPath, autoRepair: true},
stableTime: true,
overwrite: true,
stableSort: true,
});

await expect(xfs.readFilePromise(destFile, `utf8`)).resolves.toBe(`Hello World`);
} finally {
spy.mockRestore();
}
});

it(`should NOT fall back on UNKNOWN without link syscall`, async () => {
const {indexPath, sourceFile, destDir} = await setupIndexEnv();
const destFile = ppath.join(destDir, `file.txt` as Filename);

const originalLink = nodeFs.linkPromise.bind(nodeFs);
let linkCallCount = 0;
const spy = jest.spyOn(nodeFs, `linkPromise`).mockImplementation(async (existingPath, newPath) => {
linkCallCount++;
if (linkCallCount <= 1)
return originalLink(existingPath, newPath);
throw makeLinkError(`UNKNOWN`, `open`);
});

try {
await expect(
nodeFs.copyPromise(destFile, sourceFile, {
linkStrategy: {type: `HardlinkFromIndex`, indexPath, autoRepair: true},
stableTime: true,
overwrite: true,
stableSort: true,
}),
).rejects.toMatchObject({code: `UNKNOWN`});
} finally {
spy.mockRestore();
}
});

it(`should propagate non-link errors`, async () => {
const {indexPath, sourceFile, destDir} = await setupIndexEnv();
const destFile = ppath.join(destDir, `file.txt` as Filename);

const originalLink = nodeFs.linkPromise.bind(nodeFs);
let linkCallCount = 0;
const spy = jest.spyOn(nodeFs, `linkPromise`).mockImplementation(async (existingPath, newPath) => {
linkCallCount++;
if (linkCallCount <= 1)
return originalLink(existingPath, newPath);
throw makeLinkError(`EACCES`, `link`);
});

try {
await expect(
nodeFs.copyPromise(destFile, sourceFile, {
linkStrategy: {type: `HardlinkFromIndex`, indexPath, autoRepair: true},
stableTime: true,
overwrite: true,
stableSort: true,
}),
).rejects.toMatchObject({code: `EACCES`});
} finally {
spy.mockRestore();
}
});
});
});
Loading