Skip to content

fix: keep relative symlinks inside the copied fixture - #12

Merged
ho991217 merged 3 commits into
toss:mainfrom
im-ian:fix/fromdirectory-verbatim-symlinks
Sep 14, 2026
Merged

ho991217 merged 3 commits into
toss:mainfrom
im-ian:fix/fromdirectory-verbatim-symlinks

Conversation

@im-ian

@im-ian im-ian commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Overview

README.md promises that every fixture lives in its own fresh temporary directory, "so tests never step on each other". Fixture.fromDirectory breaks that promise when the source directory contains a relative symlink.

fs.cp rewrites relative symlink targets to absolute paths unless verbatimSymlinks is set, so the link inside the copy resolves back into the source directory. A test that writes through it edits the original file, and cleanup() cannot undo that: fs.rm unlinks a symlink without following it, so the fixture disappears and the edit to the repository stays. Any source directory carrying a relative symlink hits this, whether or not the link was put there on purpose.

The Node docs describe verbatimSymlinks as copying "symlinks as symlinks instead of as the reference of the symlink", which does not cover the rewrite, so the script below shows it. It runs on Node 18 or later:

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";

const source = await fs.mkdtemp(path.join(os.tmpdir(), "source-"));
await fs.writeFile(path.join(source, "a.txt"), "original");
await fs.symlink("a.txt", path.join(source, "link.txt"));

for (const options of [{ recursive: true }, { recursive: true, verbatimSymlinks: true }]) {
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "fixture-"));
  await fs.cp(source, root, options);

  console.log(JSON.stringify(options));
  console.log("  link.txt ->", await fs.readlink(path.join(root, "link.txt")));

  await fs.writeFile(path.join(root, "link.txt"), "written by the test");
  console.log("  source a.txt after the write:", await fs.readFile(path.join(source, "a.txt"), "utf-8"));

  await fs.rm(root, { recursive: true, force: true });
  console.log("  source a.txt after cleanup():", await fs.readFile(path.join(source, "a.txt"), "utf-8"));
  await fs.writeFile(path.join(source, "a.txt"), "original");
}

Output on Node 24.14.1, macOS:

{"recursive":true}
  link.txt -> /var/folders/.../T/source-6IKSlr/a.txt
  source a.txt after the write: written by the test
  source a.txt after cleanup(): written by the test

{"recursive":true,"verbatimSymlinks":true}
  link.txt -> a.txt
  source a.txt after the write: original
  source a.txt after cleanup(): original

The first block is the behavior on main: the copy points at the source, the test edits the repository, and cleanup leaves the edit behind.

Choosing verbatimSymlinks over dereference

dereference: true fixes more of the problem, since it materializes every link as a regular file and absolute links stop escaping too. It costs more than this bug is worth. It fails outright on a dangling symlink, which the other two settings copy without complaint:

{"recursive":true}                          -> OK
{"recursive":true,"verbatimSymlinks":true}  -> OK
{"recursive":true,"dereference":true}       -> THROWS ENOENT stat

It also copies the whole target when a link points at a large directory, and it discards the symlink, which breaks any fixture that exists to test symlink handling. verbatimSymlinks: true keeps the shape of the source tree and closes the path that silently edits the repository.

Absolute symlinks in the source still point outside the fixture after this change. That matches what you get by reading the source directory directly, so I left it as is. Happy to reject or dereference those as well if you would rather close that too.

verbatimSymlinks landed in Node 17.6.0, below the >=18 floor in engines.

Test

src/fixture.spec.ts gains one case, should keep a relative symlink pointing inside the copy. It builds a source directory at runtime, copies it through fromDirectory, writes through the link, then asserts that the write landed in fixture.root and that the source file is untouched.

yarn install --immutable
yarn vitest run

To see it fail, drop verbatimSymlinks: true from the fs.cp call in src/fixture.ts and run it again:

FAIL  src/fixture.spec.ts > Fixture > fromDirectory > should keep a relative symlink pointing inside the copy
AssertionError: expected 'original' to be 'written through the link'

The case is skipped on Windows, where creating a symlink needs an elevated process. The option handling in fs.cp is the same on every platform, so POSIX coverage catches a regression.

I ran yarn lint, yarn build && yarn typecheck, and yarn vitest run (50 passing) on macOS with Node 24.14.1. The Windows, Node 20, and Node 22 legs of the CI matrix have not run yet.

Checklist

  • Tests cover the change.
  • README.md and README.ko.md are updated, if user-facing behavior changed.
  • A changeset is included (yarn changeset), if the published package changed.

`fs.cp` resolves relative symlink targets to absolute paths unless
`verbatimSymlinks` is set. A link inside the source directory therefore
pointed back at the source after the copy, so writing through it from a
test modified the original tree and `cleanup()` could not undo it.

Claude-Session: https://claude.ai/code/session_01PNx3qJzFSFUwKNNTVtPFE7
@ho991217
ho991217 requested review from ho991217 and a balanced review from Copilot September 14, 2026 07:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Keep verbatimSymlinks in the copy; the README bullets and the inline
comment restated the test and the changeset.

@ho991217 ho991217 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your contribution!

@ho991217
ho991217 merged commit 5400e4e into toss:main Sep 14, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants