Description
- Operating System: macOS 14.2.1
- Node.js version: v20.11.1
fs-extra
version: 11.2.0
Issue
- create a target directory with a file in it
- create a symbolic link from somewhere else to the target directory
- do this a second time
This works if the reference to the target
is an absolute path, but fails if the reference is a relative path.
Example
This is a mocha
test that shows the issue. The first test ensures the symbolic link a second time with an absolute path, which succeeds as expected. The second test ensures the symbolic link a second time with a relative path, which is rejected with ENOENT
, but should succeed too.
/* eslint-env mocha */
require('should')
const { resolve, join, relative, dirname } = require('node:path')
const { ensureFile, remove, pathExists, ensureSymlink } = require('fs-extra')
const testBaseDirectory = resolve('fs-extra-test-base-directory')
describe('fs-extra ensureSymlink fails when ensuring a symbolic link with a relative path if it already exists', function () {
beforeEach(async function () {
// a directory with a file, as `destination` or `target`
this.targetDirectory = join(testBaseDirectory, 'target-directory')
const targetFileName = 'target-file'
this.targetDirectoryFile = join(this.targetDirectory, targetFileName)
await ensureFile(this.targetDirectoryFile)
// a directory to put the symbolic link in (the `source`)
this.linkDirectory = join(testBaseDirectory, 'link-directory')
this.symbolicLinkPath = join(this.linkDirectory, 'link')
this.targetFileViaSymbolicLink = join(this.symbolicLinkPath, targetFileName)
this.relativeSymbolicLinkReference = relative(dirname(this.symbolicLinkPath), this.targetDirectory)
})
afterEach(async function () {
return remove(testBaseDirectory)
})
it('can ensure a symbolic link a second time with an absolute path', async function () {
await pathExists(this.targetDirectoryFile).should.be.resolvedWith(true)
// first time, setting up with a relative reference
await ensureSymlink(this.relativeSymbolicLinkReference, this.symbolicLinkPath, 'dir').should.be.resolved()
await pathExists(this.symbolicLinkPath).should.be.resolvedWith(true)
await pathExists(this.targetFileViaSymbolicLink).should.be.resolvedWith(true)
// second time, setting up with an absolute reference
await ensureSymlink(this.targetDirectory, this.symbolicLinkPath, 'dir').should.be.resolved()
await pathExists(this.symbolicLinkPath).should.be.resolvedWith(true)
await pathExists(this.targetFileViaSymbolicLink).should.be.resolvedWith(true)
})
it('can ensure a symbolic link a second time with a relative path', async function () {
await pathExists(this.targetDirectoryFile).should.be.resolvedWith(true)
// first time, setting up with a relative reference
await ensureSymlink(this.relativeSymbolicLinkReference, this.symbolicLinkPath, 'dir').should.be.resolved()
await pathExists(this.symbolicLinkPath).should.be.resolvedWith(true)
await pathExists(this.targetFileViaSymbolicLink).should.be.resolvedWith(true)
// second time, setting up with a relative reference SHOULD ALSO RESOLVE, BUT REJECTS
const error = await ensureSymlink(
this.relativeSymbolicLinkReference,
this.symbolicLinkPath,
'dir'
).should.be.rejected()
error.code.should.equal('ENOENT')
// YET THE TARGET FILE EXISTS VIA THE ABSOLUTE PATH
await pathExists(this.targetDirectory).should.be.resolvedWith(true)
// AND THE RELATIVE PATH RESOLVES TO THE ABSOLUTE PATH
join(dirname(this.symbolicLinkPath), this.relativeSymbolicLinkReference).should.equal(this.targetDirectory)
})
})
Analysis
The issue is clear in fs-extra/lib/ensure/symlink.js
, line 24, versus line 32.
When there is no symbolic link yet at dstpath
, the if
of line 22 is skipped
let stats
try {
stats = await fs.lstat(dstpath)
} catch { }
if (stats && stats.isSymbolicLink()) {
…
}
and we arrive at line 31—32 where work is done to deal with relative srcpath
s:
const relative = await symlinkPaths(srcpath, dstpath)
srcpath = relative.toDst
When there is a symbolic link at dstpath
, the if
–branch at line 22 is executed. Here, the status of the srcpath
is requested as is:
fs.stat(srcpath),
This evaluates a relative srcpath
relative to the cwd
, not to the dstpath
. At that location the source does not exist, which results in ENOENT
.