Skip to content

fix(cp): detect same file reached through symlinks, junctions or hard links - #1274

Open
White0xdi3 wants to merge 1 commit into
shelljs:mainfrom
White0xdi3:fix/cp-same-file-through-links
Open

White0xdi3 wants to merge 1 commit into
shelljs:mainfrom
White0xdi3:fix/cp-same-file-through-links

Conversation

@White0xdi3

@White0xdi3 White0xdi3 commented Oct 3, 2026 •

Copy link
Copy Markdown

Note from maintainer (@nfischer): this bug is not exploitable in any publicly released ShellJS version. The reported vulnerability was introduced in #1241, which has not yet been released at time of writing. The plan is to include this bug fix in the same public release as #1241, so there will be no urgency for dependent modules to upgrade.

The same-file check from #1241 uses path.relative(), which only compares strings. When two paths reach the same file through a symlinked or junctioned directory, or a hard link, cp() opens the destination with 'w' and truncates the source to 0 bytes while returning success.

Repro on current main (real/parent/foo/x.txt exists, real/link_parent is a symlink or junction to real/parent):

shell.cd('real');
const res = shell.cp('-r', 'parent/foo', 'link_parent');
// res.code === 0, no stderr, and parent/foo/x.txt is now 0 bytes

This PR compares device and inode (falling back to realpath) before the destination is opened for writing, for single files and for the recursive directory case. Symlinks that are copied as links (-P) are compared as directory entries, so cp -P link1 link2 with a shared target keeps working.

Tests added in test/cp.js:

  • directory copied into a link to its own parent
  • file copied to itself through a linked directory
  • file copied onto a hard link of itself
  • cp -P of a symlink onto another symlink with the same target (guards against a false positive)

Local run: npx ava test/cp.js passes except 3 existing symlink tests (Test max depth., cp -L follows symlinks, Test with recursive option and symlinks.). They fail identically on main on my Windows machine, which has no admin rights for file symlinks. I could not run the suite on Linux or macOS, so please rely on CI for those.

… links

The same-file check added in shelljs#1241 compares paths with path.relative(),
which is purely lexical. When two different paths reach the same file
(e.g. via a symlinked or junctioned directory, or a hard link), cp()
opened the destination with 'w' and truncated the source to 0 bytes
while reporting success.

Compare device and inode (falling back to realpath) before opening the
destination, both for single files and for the recursive directory case.
Symlinks that are copied as links (-P) are compared as directory
entries, so copying one link onto another that points to the same
target keeps working.
Comment thread src/cp.js
// If followLinks is false, a and b are compared as directory entries (lstat).
function isSameFile(a, b, followLinks) {
try {
var statFn = followLinks ? fs.statSync : fs.lstatSync;

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.

How about using common.statFollowLinks()?

function statFollowLinks() {

Comment thread src/cp.js
// reached through different paths (symlinked or junctioned directories, hard
// links). path.relative() only compares the strings, so it cannot tell.
// If followLinks is false, a and b are compared as directory entries (lstat).
function isSameFile(a, b, followLinks) {

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.

The documentation for this method seems contradictory. You say that this tests if files are really the same on disk, but then you offer an option for followLinks which lets a caller turn off the documented behavior.

Why do we need the case for followLinks == false?

Comment thread test/cp.js

// Directory symlinks use junctions on Windows, which do not need admin rights
function linkDir(target, linkPath) {
fs.symlinkSync(path.resolve(target), linkPath, 'junction');

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.

Can we achieve the same with shell.ln()?

@codecov

codecov Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.12%. Comparing base (f364da6) to head (47af520).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1274      +/-   ##
==========================================
+ Coverage   97.10%   97.12%   +0.02%     
==========================================
  Files          36       36              
  Lines        1518     1530      +12     
==========================================
+ Hits         1474     1486      +12     
  Misses         44       44              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.

2 participants