Skip to content

Cache restore replaces a symlinked output directory with a real directory (regression in 23.0.2, #36116) #37061

Description

@rhermes62

Current Behavior

When a cached task's outputs path passes through a symlinked directory inside the workspace, restoring that task from cache deletes the symlink and creates a real directory in its place. The build artifacts land in the new local directory, and the directory the symlink pointed at is left empty and orphaned.

Only cache hits trigger it. A cache miss (task actually runs) writes through the symlink correctly, and [existing outputs match the cache, left as is] leaves it alone — so the failure appears non-deterministically from a user's point of view, on the second build.

Bisected to 23.0.2, introduced by #36116 (fix(core): prevent path traversal / zip-slip in self-hosted remote cache, commit ad296578f). It is the only commit touching packages/nx/src/native/cache/file_ops.rs in 23.0.1...23.0.2, and the behavior is explicit in the new create_dir_all_within(boundary, dir):

Ok(_) => {
    // Replace a symlink/file occupying the path with a real directory.
    remove_path(dir)?;
    fs::create_dir(dir)
}
version result
22.7.5, 23.0.0, 23.0.1 symlink preserved
23.0.2, 23.1.0, 23.1.3, 23.2.0, 23.2.1, 23.3.0-canary.20260914 symlink replaced with real directory

I understand the hardening intent — a malicious artifact must not write through a symlink. The problem is that it applies to symlinks the user created in their own workspace, not just ones arriving from a cache artifact, and there is no way to opt out.

Expected Behavior

Restoring from the local cache should not modify the workspace's own directory structure. A pre-existing, user-created symlink on the path to a declared output should be written through, as in ≤ 23.0.1.

If write-through must stay blocked by default, some escape hatch would work — e.g. only apply create_dir_all_within to remote-cache restores (the threat model in #36116), honor an opt-in like nx.json → cacheRestoreFollowsSymlinks / NX_CACHE_RESTORE_FOLLOW_SYMLINKS, or at minimum fail loudly instead of silently relocating output, so the user finds out before their next build breaks.

GitHub Repo

No response

Steps to Reproduce

mkdir -p /tmp/nx-repro/pkg /tmp/nx-repro/external-build && cd /tmp/nx-repro

cat > package.json <<'EOF'
{ "name": "repro", "private": true, "version": "0.0.1", "workspaces": ["pkg"],
  "devDependencies": { "nx": "23.2.1" } }
EOF

cat > nx.json <<'EOF'
{ "targetDefaults": { "build": { "cache": true } }, "neverConnectToCloud": true }
EOF

cat > pkg/package.json <<'EOF'
{ "name": "pkg", "version": "0.0.1", "private": true,
  "scripts": { "build": "mkdir -p ../build/pkg && echo built > ../build/pkg/index.js" },
  "nx": { "targets": { "build": { "outputs": ["{workspaceRoot}/build/pkg"] } } } }
EOF

ln -s /tmp/nx-repro/external-build build   # workspace-root `build` is a symlink
npm install

npx nx run pkg:build     # cache miss
ls -ld build             # symlink, intact — correct

rm -rf build/pkg         # force a restore
npx nx run pkg:build     # cache hit, "read the output from the cache"
ls -ld build             # ← now a real directory
ls external-build        # ← empty; the symlink target was abandoned

Swap "nx": "23.2.1" for "23.0.1" and the final ls -ld build stays a symlink.

Nx Report

Node           : 24.19.0
OS             : linux-x64
Native Target  : x86_64-linux
npm            : 11.17.0
daemon         : Available
nx             : 23.2.1

Failure Logs

Package Manager Version

No response

Operating System

  • macOS
  • Linux
  • Windows
  • Other (Please specify)

Additional Information

Why this matters in practice.

Many companies have internal tooling that leverage symlinks for build artifacts.

For example, Amazon uses an internal build system called Brazil which expected build/ to be a symlink. Teams at Amazon would routinely have nx emit to build/ and it be cached.

However, nx 23.0.2 in #36116 breaks this long supported artifact pattern preventing teams from adopting nx >=23.0.2.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

priority: highHigh Priority (important issues which affect many people severely)scope: corecore nx functionalitytype: bug

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions