Skip to content

fix(install): don't write through hardlinks when copying package files#35735

Merged
bartlomieju merged 3 commits into
denoland:mainfrom
yyq1025:fix-npm-copy-hardlink-clobber
Jul 6, 2026
Merged

fix(install): don't write through hardlinks when copying package files#35735
bartlomieju merged 3 commits into
denoland:mainfrom
yyq1025:fix-npm-copy-hardlink-clobber

Conversation

@yyq1025

@yyq1025 yyq1025 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #35734

copy_dir_recursive copied over existing destination files in place. If a lifecycle script had replaced such a file with a hardlink to another path — esbuild's install script hardlinks the platform package's native binary over its own JS shim — the copy wrote through the link and corrupted the file at its other paths, leaving @esbuild/<platform>/bin/esbuild containing the JS shim, which then spawns itself in an infinite loop (EAGAIN).

  • remove the destination before fs_copy in copy_dir_recursive and in the hard_link_file fallback in local.rs, so copies always write a new inode instead of writing through a hardlinked destination
  • this generalizes the previous Linux-only ETXTBSY handling, which relied on the copy failing and so never covered hardlinked files that weren't currently executing; that special case is now subsumed
  • regression test: hardlink a file over the copy destination, run copy_dir_recursive, assert the other end of the link is untouched — fails before this change, passes after

🤖 Generated with Claude Code

When re-syncing an npm package directory, copy_dir_recursive copied over
existing destination files in place. If a lifecycle script had replaced
such a file with a hardlink to another path (esbuild's install script
hardlinks the platform package's native binary over its own JS shim),
the copy wrote through the link and corrupted the file at its other
paths, e.g. leaving @esbuild/<platform>/bin/esbuild containing the JS
shim, which then spawns itself in an infinite loop (EAGAIN).

Remove the destination before copying so the copy always writes a new
inode. This also generalizes the previous Linux-only ETXTBSY handling,
which relied on the copy failing and so never covered hardlinked files
that were not currently executing.

Co-Authored-By: Claude Fable 5 <[email protected]>
@yyq1025
yyq1025 force-pushed the fix-npm-copy-hardlink-clobber branch from d1d5937 to a586415 Compare July 2, 2026 21:30
@bartlomieju

Copy link
Copy Markdown
Member

Reviewed — the fix looks correct, and it actually protects more than the reported esbuild case: since clone_dir_recursive falls back to hard_link_dir_recursive, files in node_modules are frequently hardlinks into the global npm cache, and overwrite-in-place could previously write through into the cache entry as well. Remove-before-copy closes that hole too.

A few suggestions:

  1. Dead code: this PR removes the only two call sites of deno_npm_cache::is_etxtbsy. After this lands, the helper in libs/npm_cache/fs_util.rs and its re-export in lib.rs are unused — could you remove them here as well? The comment in hard_link_file ("Callers should handle this by falling back to copy") can stay, since the fallback is still a copy.

  2. Per-file overhead (very minor): every copied file now pays an extra unlink even on fresh installs where the destination doesn't exist. It's one cheap syscall in an already I/O-heavy path, so I don't think it's worth complicating the code over — just noting it was considered.

  3. Test coverage: the unit test is well targeted (asserting the other end of the link is untouched is exactly the pre-fix failure). An end-to-end spec test that re-installs after a lifecycle script hardlinks over a package file would guard the full path, but given how awkward lifecycle-script specs are to set up, the unit test seems like an acceptable trade-off.

This PR removed its only two call sites; drop the helper and its
re-export from deno_npm_cache.

Co-Authored-By: Claude Fable 5 <[email protected]>
@yyq1025

yyq1025 commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Done in 4080c1b — removed is_etxtbsy and its re-export from deno_npm_cache. The remaining ETXTBSY mentions are all comments, including the one in hard_link_file you mentioned.

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, thanks!

@bartlomieju
bartlomieju merged commit a885095 into denoland:main Jul 6, 2026
136 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.

npm package re-sync writes through hardlinks created by lifecycle scripts, corrupting esbuild's platform binary

2 participants