Keep regular destination files when fs-extra's copy meets a symlink source

copy and copySync now follow fs-extra's onLink: only a destination that
is itself a symlink is replaced; over a regular file or directory the
symlink call fails with EEXIST and the destination is kept, whatever
overwrite says. The fs-extra corpus program covers both forms.
This commit is contained in:
Chris Tate
2026-10-20 10:36:00 -05:00
parent aaf1e83ad8
commit 313a984066
2 changed files with 95 additions and 6 deletions
+71 -6
View File
@@ -925,6 +925,64 @@ async function included(state: CopyState, src: string, dest: string): Promise<bo
return await filter(src, dest);
}
/** fs-extra's onLink: a symlink source creates a symlink at `dest`. Only
* a destination that is itself a symlink is replaced; over a regular file
* or a directory, symlink fails with EEXIST, whatever `overwrite` says. */
async function copyLink(
target: string,
dest: string,
destExists: boolean,
dereference: boolean,
): Promise<void> {
if (!destExists) return fsp.symlink(target, dest);
let destLinked: string;
try {
destLinked = await fsp.readlink(dest);
} catch (err) {
const code = codeOf(err);
if (code === "EINVAL" || code === "UNKNOWN") return fsp.symlink(target, dest);
throw err;
}
const destTarget = dereference ? path.resolve(process.cwd(), destLinked) : destLinked;
checkLinkTargets(target, destTarget, (await fsp.stat(dest)).isDirectory());
await fsp.unlink(dest);
await fsp.symlink(target, dest);
}
function copyLinkSync(
target: string,
dest: string,
destExists: boolean,
dereference: boolean,
): void {
if (!destExists) {
fs.symlinkSync(target, dest);
return;
}
let destLinked: string;
try {
destLinked = fs.readlinkSync(dest);
} catch (err) {
const code = codeOf(err);
if (code !== "EINVAL" && code !== "UNKNOWN") throw err;
fs.symlinkSync(target, dest);
return;
}
const destTarget = dereference ? path.resolve(process.cwd(), destLinked) : destLinked;
checkLinkTargets(target, destTarget, fs.statSync(dest).isDirectory());
fs.unlinkSync(dest);
fs.symlinkSync(target, dest);
}
/** Replacing a destination symlink never points it into its own target,
* nor removes a directory link the source lives under. */
function checkLinkTargets(target: string, destTarget: string, destIsDirectory: boolean): void {
if (isSrcSubdir(target, destTarget))
throw new Error(`Cannot copy '${target}' to a subdirectory of itself, '${destTarget}'.`);
if (destIsDirectory && isSrcSubdir(destTarget, target))
throw new Error(`Cannot overwrite '${destTarget}' with '${target}'.`);
}
async function copyEntry(
state: CopyState,
src: string,
@@ -954,9 +1012,12 @@ async function copyEntry(
}
if (srcStat.isSymbolicLink()) {
const linked = await fsp.readlink(src);
const target = state.dereference ? path.resolve(process.cwd(), linked) : linked;
if (destExists) await fsp.unlink(dest);
await fsp.symlink(target, dest);
await copyLink(
state.dereference ? path.resolve(process.cwd(), linked) : linked,
dest,
destExists,
state.dereference,
);
return;
}
if (destExists) {
@@ -1027,10 +1088,14 @@ function copyEntrySync(
return;
}
if (srcStat.isSymbolicLink()) {
const dereference = state.dereference === true;
const linked = fs.readlinkSync(src);
const target = state.dereference === true ? path.resolve(process.cwd(), linked) : linked;
if (destExists) fs.unlinkSync(dest);
fs.symlinkSync(target, dest);
copyLinkSync(
dereference ? path.resolve(process.cwd(), linked) : linked,
dest,
destExists,
dereference,
);
return;
}
if (destExists) {
+24
View File
@@ -115,6 +115,30 @@ async function main(): Promise<void> {
}
console.log(fs.existsSync(file), fs.existsSync(path.join(root, "deep", "inner")));
// A symlink source over an existing destination entry: a regular file is
// kept and the copy fails with EEXIST; a destination symlink is replaced.
const linkSrc = path.join(root, "link-src");
fs.mkdirSync(linkSrc);
fs.symlinkSync(file, path.join(linkSrc, "entry"));
for (const sync of [true, false]) {
const linkDest = path.join(root, sync ? "link-dest-sync" : "link-dest-async");
fs.mkdirSync(linkDest);
fs.writeFileSync(path.join(linkDest, "entry"), "kept");
try {
if (sync) fs.copySync(linkSrc, linkDest, { overwrite: false });
else await fs.copy(linkSrc, linkDest, { overwrite: false });
} catch (err) {
console.log((err as NodeJS.ErrnoException).code, shown(root, (err as Error).message));
}
const entry = path.join(linkDest, "entry");
console.log(fs.readFileSync(entry, "utf8"), fs.lstatSync(entry).isSymbolicLink());
}
const relinkDest = path.join(root, "relink-dest");
fs.mkdirSync(relinkDest);
fs.symlinkSync(data, path.join(relinkDest, "entry"));
fs.copySync(linkSrc, relinkDest);
console.log(shown(root, fs.readlinkSync(path.join(relinkDest, "entry"))));
console.log(await readStream(file));
console.log((await fs.promises.readFile(file, "utf8")).length);
console.log(fs.readFileSync(file, "utf8").length);