diff --git a/packages/yarnpkg-fslib/sources/algorithms/copyPromise.ts b/packages/yarnpkg-fslib/sources/algorithms/copyPromise.ts index b6b0b85d6723..f60b514eeb01 100644 --- a/packages/yarnpkg-fslib/sources/algorithms/copyPromise.ts +++ b/packages/yarnpkg-fslib/sources/algorithms/copyPromise.ts @@ -171,6 +171,28 @@ async function copyFolder(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(prelayout: Operations, postlayout: Operations, destinationFs: FakeFS, destination: P1, destinationStat: Stats | null, sourceFs: FakeFS, source: P2, sourceStat: Stats, opts: CopyOptions, linkStrategy: HardlinkFromIndexStrategy) { const sourceHash = await sourceFs.checksumFilePromise(source, {algorithm: `sha1`}); @@ -264,7 +286,19 @@ async function copyFileViaIndex(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; + } + } } }); diff --git a/packages/yarnpkg-fslib/tests/copyPromise.test.ts b/packages/yarnpkg-fslib/tests/copyPromise.test.ts new file mode 100644 index 000000000000..7c1c2cd9d9fe --- /dev/null +++ b/packages/yarnpkg-fslib/tests/copyPromise.test.ts @@ -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(); + } + }); + }); +});