From 1c937aadb3bf1e9db01e96e27c6b190863465d85 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Sat, 12 Sep 2026 02:21:27 +0200 Subject: [PATCH 1/2] fix: take the shim header's helpers off the caller's PATH The header of every POSIX bin shim resolved `readlink`, `dirname`, `sed`, and `uname` by bare name. A shim runs with `node_modules/.bin` at the front of `PATH` by design, which is where a dependency's own bins live, so a dependency could supply any of those four names. The shim runs it before reaching its target, and its answer decides the directory the shim execs from. `readlink`, `sed`, and `uname` now go through `command -p`, which searches the POSIX system default path rather than the caller's. `dirname` is dropped for `${link%/*}`, which removes two forks per shim invocation. `${link%/*}` needs a separator to strip, so `$0` is normalized to `./name` when a `PATH` lookup left it a bare name. `cygpath` and `wslpath` are left resolving through `PATH`. `command -p` helps only if the helper is on the system default path, and that is not verifiable for two tools that exist only on Cygwin, MSYS2, and WSL, where no CI job runs. A `command -p cygpath` that came up empty would leave `basedir_win` a POSIX path that `node.exe` cannot open. The header is byte-identical to the one pnpm v12 generates after https://github.com/pnpm/pnpm/pull/14845, so pacquet keeps recognizing shims this package wrote. Ref https://github.com/pnpm/pnpm/issues/14837 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01W5f2VB7CDEj1hu1SEy5chh --- src/index.ts | 42 ++++- test/e2e.test.js | 66 ++++++++ test/e2e.test.js.snapshot | 21 ++- test/test.js.snapshot | 315 ++++++++++++++++++++++++++++++-------- 4 files changed, 372 insertions(+), 72 deletions(-) diff --git a/src/index.ts b/src/index.ts index a335b88..4d5a6fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -471,22 +471,35 @@ function generateShShim (src: string, to: string, opts: InternalOptions): string // #!/bin/sh // # Resolve $0 through symlinks so basedir is the shim's real directory. // # Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim. + // + // # A shim runs with node_modules/.bin at the front of PATH, so readlink, sed, and + // # uname go through `command -p`, which searches the system default path instead. + // # A dependency's bin cannot stand in for one of them and take over the shim + // # before it reaches its target. Directories come from `${link%/*}`, which needs + // # no helper at all. // link="$0" + // # `${link%/*}` needs a separator to strip. A bare name came from a PATH lookup + // # and stands for a file in the current directory. + // case "$link" in + // */*|*\\*) ;; + // *) link="./$link" ;; + // esac // hops=0 // while [ -L "$link" ] && [ "$hops" -lt 40 ]; do // hops=$((hops+1)) - // target=$(readlink "$link") + // target=$(command -p readlink "$link") // case "$target" in // /*) link="$target" ;; - // *) link="$(dirname "$link")/$target" ;; + // *) link="${link%/*}/$target" ;; // esac // done - // basedir=$(dirname "$(echo "$link" | sed -e 's,\\,/,g')") + // basedir=$(echo "$link" | command -p sed -e 's,\\,/,g') + // basedir="${basedir%/*}" // basedir_win="$basedir" // exe="" // msys="" // - // case `uname -a` in + // case `command -p uname -a` in // *CYGWIN*|*MINGW*|*MSYS*) // if command -v cygpath > /dev/null 2>&1; then // basedir_win=`cygpath -w "$basedir"` @@ -524,22 +537,35 @@ function generateShShim (src: string, to: string, opts: InternalOptions): string #!/bin/sh # Resolve $0 through symlinks so basedir is the shim's real directory. # Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim. +# +# A shim runs with node_modules/.bin at the front of PATH, so readlink, sed, and +# uname go through \`command -p\`, which searches the system default path instead. +# A dependency's bin cannot stand in for one of them and take over the shim +# before it reaches its target. Directories come from \`\${link%/*}\`, which needs +# no helper at all. link="$0" +# \`\${link%/*}\` needs a separator to strip. A bare name came from a PATH lookup +# and stands for a file in the current directory. +case "$link" in + */*|*\\\\*) ;; + *) link="./$link" ;; +esac hops=0 while [ -L "$link" ] && [ "$hops" -lt 40 ]; do hops=$((hops+1)) - target=$(readlink "$link") + target=$(command -p readlink "$link") case "$target" in /*) link="$target" ;; - *) link="$(dirname "$link")/$target" ;; + *) link="\${link%/*}/$target" ;; esac done -basedir=$(dirname "$(echo "$link" | sed -e 's,\\\\,/,g')") +basedir=$(echo "$link" | command -p sed -e 's,\\\\,/,g') +basedir="\${basedir%/*}" basedir_win="$basedir" exe="" msys="" -case \`uname -a\` in +case \`command -p uname -a\` in *CYGWIN*|*MINGW*|*MSYS*) if command -v cygpath > /dev/null 2>&1; then basedir_win=\`cygpath -w "$basedir"\` diff --git a/test/e2e.test.js b/test/e2e.test.js index f721f39..366ff69 100644 --- a/test/e2e.test.js +++ b/test/e2e.test.js @@ -198,3 +198,69 @@ describeOnPosix('sh shim invoked through a chain of external symlinks', () => { assert.equal(runShim(hop), 'NODE_BIN_OK') }) }) + +describeOnPosix('sh shim resolves its helpers off the caller\'s PATH', () => { + // A shim runs with node_modules/.bin at the front of PATH, which is where a + // dependency's own bins live, so a helper taken from there could report any + // directory it liked and redirect what the shim finally execs + // (https://github.com/pnpm/pnpm/issues/14837). + const writeExecutable = (file, body) => { + fs.writeFileSync(file, body, 'utf8') + fs.chmodSync(file, 0o755) + } + + // Write the tree the decoys point at, and the decoys, returning the directory + // to put at the front of PATH. Each decoy answers with what its real + // counterpart would be asked for, so any one of them alone is enough to + // redirect the shim. + const plantHijackTreeAndDecoys = (tempDir) => { + const hijack = path.join(tempDir, 'hijack', 'node_modules') + const hijackBin = path.join(hijack, '.bin') + const hijackTarget = path.join(hijack, 'typescript', 'bin', 'tsc.js') + fs.mkdirSync(hijackBin, { recursive: true }) + fs.mkdirSync(path.dirname(hijackTarget), { recursive: true }) + fs.writeFileSync(hijackTarget, 'console.log("hijacked")\n', 'utf8') + + const decoyDir = path.join(tempDir, 'decoy') + fs.mkdirSync(decoyDir) + const answer = (p) => `#!/bin/sh\necho '${p}'\n` + for (const helper of ['readlink', 'sed']) { + writeExecutable(path.join(decoyDir, helper), answer(path.join(hijackBin, 'tsc'))) + } + writeExecutable(path.join(decoyDir, 'dirname'), answer(hijackBin)) + writeExecutable(path.join(decoyDir, 'uname'), '#!/bin/sh\necho MINGW64_NT-10.0\n') + return decoyDir + } + + test('reaches its target with decoy readlink, dirname, sed, and uname first on PATH', async () => { + const tempDir = tempy.directory() + const binDir = path.join(tempDir, 'node_modules', '.bin') + const target = path.join(tempDir, 'node_modules', 'typescript', 'bin', 'tsc.js') + fs.mkdirSync(binDir, { recursive: true }) + fs.mkdirSync(path.dirname(target), { recursive: true }) + fs.writeFileSync(target, 'console.log("tsc-output")\n', 'utf8') + // A dependency can declare a bin named node.exe, and the shim's basedir is + // the directory those bins land in. Only a lying uname reaches it. + writeExecutable(path.join(binDir, 'node.exe'), '#!/bin/sh\necho hijacked\n') + + const shim = path.join(binDir, 'tsc') + await cmdShim(target, shim, { createCmdFile: false }) + // Relative and in the shim's own directory, so the walk composes a + // directory with the link target instead of taking one from readlink. + const link = path.join(binDir, 'tsc-link') + fs.symlinkSync('tsc', link) + + const decoyDir = plantHijackTreeAndDecoys(tempDir) + const r = spawnSync(link, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + PATH: [decoyDir, path.dirname(process.execPath), process.env.PATH].join(path.delimiter), + }, + }) + + assert.equal(r.status, 0, `shim exited ${r.status}\nstdout: ${r.stdout}\nstderr: ${r.stderr}`) + assert.equal(r.stdout.trim(), 'tsc-output', 'the shim took a helper from the caller\'s PATH') + }) +}) diff --git a/test/e2e.test.js.snapshot b/test/e2e.test.js.snapshot index 20db918..9719dd1 100644 --- a/test/e2e.test.js.snapshot +++ b/test/e2e.test.js.snapshot @@ -3,22 +3,35 @@ exports[`create a command shim for a .exe file > shim files 1`] = ` #!/bin/sh # Resolve $0 through symlinks so basedir is the shim's real directory. # Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim. +# +# A shim runs with node_modules/.bin at the front of PATH, so readlink, sed, and +# uname go through \`command -p\`, which searches the system default path instead. +# A dependency's bin cannot stand in for one of them and take over the shim +# before it reaches its target. Directories come from \`\${link%/*}\`, which needs +# no helper at all. link="$0" +# \`\${link%/*}\` needs a separator to strip. A bare name came from a PATH lookup +# and stands for a file in the current directory. +case "$link" in + */*|*\\\\*) ;; + *) link="./$link" ;; +esac hops=0 while [ -L "$link" ] && [ "$hops" -lt 40 ]; do hops=$((hops+1)) - target=$(readlink "$link") + target=$(command -p readlink "$link") case "$target" in /*) link="$target" ;; - *) link="$(dirname "$link")/$target" ;; + *) link="\${link%/*}/$target" ;; esac done -basedir=$(dirname "$(echo "$link" | sed -e 's,\\\\,/,g')") +basedir=$(echo "$link" | command -p sed -e 's,\\\\,/,g') +basedir="\${basedir%/*}" basedir_win="$basedir" exe="" msys="" -case \`uname -a\` in +case \`command -p uname -a\` in *CYGWIN*|*MINGW*|*MSYS*) if command -v cygpath > /dev/null 2>&1; then basedir_win=\`cygpath -w "$basedir"\` diff --git a/test/test.js.snapshot b/test/test.js.snapshot index 9b29f19..d88ffa8 100644 --- a/test/test.js.snapshot +++ b/test/test.js.snapshot @@ -3,22 +3,35 @@ exports[`batch script > shim files > bat.shim 1`] = ` #!/bin/sh # Resolve $0 through symlinks so basedir is the shim's real directory. # Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim. +# +# A shim runs with node_modules/.bin at the front of PATH, so readlink, sed, and +# uname go through \`command -p\`, which searches the system default path instead. +# A dependency's bin cannot stand in for one of them and take over the shim +# before it reaches its target. Directories come from \`\${link%/*}\`, which needs +# no helper at all. link="$0" +# \`\${link%/*}\` needs a separator to strip. A bare name came from a PATH lookup +# and stands for a file in the current directory. +case "$link" in + */*|*\\\\*) ;; + *) link="./$link" ;; +esac hops=0 while [ -L "$link" ] && [ "$hops" -lt 40 ]; do hops=$((hops+1)) - target=$(readlink "$link") + target=$(command -p readlink "$link") case "$target" in /*) link="$target" ;; - *) link="$(dirname "$link")/$target" ;; + *) link="\${link%/*}/$target" ;; esac done -basedir=$(dirname "$(echo "$link" | sed -e 's,\\\\,/,g')") +basedir=$(echo "$link" | command -p sed -e 's,\\\\,/,g') +basedir="\${basedir%/*}" basedir_win="$basedir" exe="" msys="" -case \`uname -a\` in +case \`command -p uname -a\` in *CYGWIN*|*MINGW*|*MSYS*) if command -v cygpath > /dev/null 2>&1; then basedir_win=\`cygpath -w "$basedir"\` @@ -118,22 +131,35 @@ exports[`custom node executable > shim files > env.shim 1`] = ` #!/bin/sh # Resolve $0 through symlinks so basedir is the shim's real directory. # Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim. +# +# A shim runs with node_modules/.bin at the front of PATH, so readlink, sed, and +# uname go through \`command -p\`, which searches the system default path instead. +# A dependency's bin cannot stand in for one of them and take over the shim +# before it reaches its target. Directories come from \`\${link%/*}\`, which needs +# no helper at all. link="$0" +# \`\${link%/*}\` needs a separator to strip. A bare name came from a PATH lookup +# and stands for a file in the current directory. +case "$link" in + */*|*\\\\*) ;; + *) link="./$link" ;; +esac hops=0 while [ -L "$link" ] && [ "$hops" -lt 40 ]; do hops=$((hops+1)) - target=$(readlink "$link") + target=$(command -p readlink "$link") case "$target" in /*) link="$target" ;; - *) link="$(dirname "$link")/$target" ;; + *) link="\${link%/*}/$target" ;; esac done -basedir=$(dirname "$(echo "$link" | sed -e 's,\\\\,/,g')") +basedir=$(echo "$link" | command -p sed -e 's,\\\\,/,g') +basedir="\${basedir%/*}" basedir_win="$basedir" exe="" msys="" -case \`uname -a\` in +case \`command -p uname -a\` in *CYGWIN*|*MINGW*|*MSYS*) if command -v cygpath > /dev/null 2>&1; then basedir_win=\`cygpath -w "$basedir"\` @@ -191,22 +217,35 @@ exports[`env shebang > shim files > env.shim 1`] = ` #!/bin/sh # Resolve $0 through symlinks so basedir is the shim's real directory. # Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim. +# +# A shim runs with node_modules/.bin at the front of PATH, so readlink, sed, and +# uname go through \`command -p\`, which searches the system default path instead. +# A dependency's bin cannot stand in for one of them and take over the shim +# before it reaches its target. Directories come from \`\${link%/*}\`, which needs +# no helper at all. link="$0" +# \`\${link%/*}\` needs a separator to strip. A bare name came from a PATH lookup +# and stands for a file in the current directory. +case "$link" in + */*|*\\\\*) ;; + *) link="./$link" ;; +esac hops=0 while [ -L "$link" ] && [ "$hops" -lt 40 ]; do hops=$((hops+1)) - target=$(readlink "$link") + target=$(command -p readlink "$link") case "$target" in /*) link="$target" ;; - *) link="$(dirname "$link")/$target" ;; + *) link="\${link%/*}/$target" ;; esac done -basedir=$(dirname "$(echo "$link" | sed -e 's,\\\\,/,g')") +basedir=$(echo "$link" | command -p sed -e 's,\\\\,/,g') +basedir="\${basedir%/*}" basedir_win="$basedir" exe="" msys="" -case \`uname -a\` in +case \`command -p uname -a\` in *CYGWIN*|*MINGW*|*MSYS*) if command -v cygpath > /dev/null 2>&1; then basedir_win=\`cygpath -w "$basedir"\` @@ -290,22 +329,35 @@ exports[`env shebang with NODE_PATH > shim files > env.shim 1`] = ` #!/bin/sh # Resolve $0 through symlinks so basedir is the shim's real directory. # Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim. +# +# A shim runs with node_modules/.bin at the front of PATH, so readlink, sed, and +# uname go through \`command -p\`, which searches the system default path instead. +# A dependency's bin cannot stand in for one of them and take over the shim +# before it reaches its target. Directories come from \`\${link%/*}\`, which needs +# no helper at all. link="$0" +# \`\${link%/*}\` needs a separator to strip. A bare name came from a PATH lookup +# and stands for a file in the current directory. +case "$link" in + */*|*\\\\*) ;; + *) link="./$link" ;; +esac hops=0 while [ -L "$link" ] && [ "$hops" -lt 40 ]; do hops=$((hops+1)) - target=$(readlink "$link") + target=$(command -p readlink "$link") case "$target" in /*) link="$target" ;; - *) link="$(dirname "$link")/$target" ;; + *) link="\${link%/*}/$target" ;; esac done -basedir=$(dirname "$(echo "$link" | sed -e 's,\\\\,/,g')") +basedir=$(echo "$link" | command -p sed -e 's,\\\\,/,g') +basedir="\${basedir%/*}" basedir_win="$basedir" exe="" msys="" -case \`uname -a\` in +case \`command -p uname -a\` in *CYGWIN*|*MINGW*|*MSYS*) if command -v cygpath > /dev/null 2>&1; then basedir_win=\`cygpath -w "$basedir"\` @@ -412,22 +464,35 @@ exports[`env shebang with PATH extending > shim files > env.shim 1`] = ` #!/bin/sh # Resolve $0 through symlinks so basedir is the shim's real directory. # Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim. +# +# A shim runs with node_modules/.bin at the front of PATH, so readlink, sed, and +# uname go through \`command -p\`, which searches the system default path instead. +# A dependency's bin cannot stand in for one of them and take over the shim +# before it reaches its target. Directories come from \`\${link%/*}\`, which needs +# no helper at all. link="$0" +# \`\${link%/*}\` needs a separator to strip. A bare name came from a PATH lookup +# and stands for a file in the current directory. +case "$link" in + */*|*\\\\*) ;; + *) link="./$link" ;; +esac hops=0 while [ -L "$link" ] && [ "$hops" -lt 40 ]; do hops=$((hops+1)) - target=$(readlink "$link") + target=$(command -p readlink "$link") case "$target" in /*) link="$target" ;; - *) link="$(dirname "$link")/$target" ;; + *) link="\${link%/*}/$target" ;; esac done -basedir=$(dirname "$(echo "$link" | sed -e 's,\\\\,/,g')") +basedir=$(echo "$link" | command -p sed -e 's,\\\\,/,g') +basedir="\${basedir%/*}" basedir_win="$basedir" exe="" msys="" -case \`uname -a\` in +case \`command -p uname -a\` in *CYGWIN*|*MINGW*|*MSYS*) if command -v cygpath > /dev/null 2>&1; then basedir_win=\`cygpath -w "$basedir"\` @@ -523,22 +588,35 @@ exports[`env shebang with args > shim files > env.args.shim 1`] = ` #!/bin/sh # Resolve $0 through symlinks so basedir is the shim's real directory. # Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim. +# +# A shim runs with node_modules/.bin at the front of PATH, so readlink, sed, and +# uname go through \`command -p\`, which searches the system default path instead. +# A dependency's bin cannot stand in for one of them and take over the shim +# before it reaches its target. Directories come from \`\${link%/*}\`, which needs +# no helper at all. link="$0" +# \`\${link%/*}\` needs a separator to strip. A bare name came from a PATH lookup +# and stands for a file in the current directory. +case "$link" in + */*|*\\\\*) ;; + *) link="./$link" ;; +esac hops=0 while [ -L "$link" ] && [ "$hops" -lt 40 ]; do hops=$((hops+1)) - target=$(readlink "$link") + target=$(command -p readlink "$link") case "$target" in /*) link="$target" ;; - *) link="$(dirname "$link")/$target" ;; + *) link="\${link%/*}/$target" ;; esac done -basedir=$(dirname "$(echo "$link" | sed -e 's,\\\\,/,g')") +basedir=$(echo "$link" | command -p sed -e 's,\\\\,/,g') +basedir="\${basedir%/*}" basedir_win="$basedir" exe="" msys="" -case \`uname -a\` in +case \`command -p uname -a\` in *CYGWIN*|*MINGW*|*MSYS*) if command -v cygpath > /dev/null 2>&1; then basedir_win=\`cygpath -w "$basedir"\` @@ -622,22 +700,35 @@ exports[`env shebang with default args > shim files > env.shim 1`] = ` #!/bin/sh # Resolve $0 through symlinks so basedir is the shim's real directory. # Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim. +# +# A shim runs with node_modules/.bin at the front of PATH, so readlink, sed, and +# uname go through \`command -p\`, which searches the system default path instead. +# A dependency's bin cannot stand in for one of them and take over the shim +# before it reaches its target. Directories come from \`\${link%/*}\`, which needs +# no helper at all. link="$0" +# \`\${link%/*}\` needs a separator to strip. A bare name came from a PATH lookup +# and stands for a file in the current directory. +case "$link" in + */*|*\\\\*) ;; + *) link="./$link" ;; +esac hops=0 while [ -L "$link" ] && [ "$hops" -lt 40 ]; do hops=$((hops+1)) - target=$(readlink "$link") + target=$(command -p readlink "$link") case "$target" in /*) link="$target" ;; - *) link="$(dirname "$link")/$target" ;; + *) link="\${link%/*}/$target" ;; esac done -basedir=$(dirname "$(echo "$link" | sed -e 's,\\\\,/,g')") +basedir=$(echo "$link" | command -p sed -e 's,\\\\,/,g') +basedir="\${basedir%/*}" basedir_win="$basedir" exe="" msys="" -case \`uname -a\` in +case \`command -p uname -a\` in *CYGWIN*|*MINGW*|*MSYS*) if command -v cygpath > /dev/null 2>&1; then basedir_win=\`cygpath -w "$basedir"\` @@ -721,22 +812,35 @@ exports[`env shebang with no NODE_PATH > shim files > env.shim 1`] = ` #!/bin/sh # Resolve $0 through symlinks so basedir is the shim's real directory. # Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim. +# +# A shim runs with node_modules/.bin at the front of PATH, so readlink, sed, and +# uname go through \`command -p\`, which searches the system default path instead. +# A dependency's bin cannot stand in for one of them and take over the shim +# before it reaches its target. Directories come from \`\${link%/*}\`, which needs +# no helper at all. link="$0" +# \`\${link%/*}\` needs a separator to strip. A bare name came from a PATH lookup +# and stands for a file in the current directory. +case "$link" in + */*|*\\\\*) ;; + *) link="./$link" ;; +esac hops=0 while [ -L "$link" ] && [ "$hops" -lt 40 ]; do hops=$((hops+1)) - target=$(readlink "$link") + target=$(command -p readlink "$link") case "$target" in /*) link="$target" ;; - *) link="$(dirname "$link")/$target" ;; + *) link="\${link%/*}/$target" ;; esac done -basedir=$(dirname "$(echo "$link" | sed -e 's,\\\\,/,g')") +basedir=$(echo "$link" | command -p sed -e 's,\\\\,/,g') +basedir="\${basedir%/*}" basedir_win="$basedir" exe="" msys="" -case \`uname -a\` in +case \`command -p uname -a\` in *CYGWIN*|*MINGW*|*MSYS*) if command -v cygpath > /dev/null 2>&1; then basedir_win=\`cygpath -w "$basedir"\` @@ -820,22 +924,35 @@ exports[`explicit shebang > shim files > sh.shim 1`] = ` #!/bin/sh # Resolve $0 through symlinks so basedir is the shim's real directory. # Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim. +# +# A shim runs with node_modules/.bin at the front of PATH, so readlink, sed, and +# uname go through \`command -p\`, which searches the system default path instead. +# A dependency's bin cannot stand in for one of them and take over the shim +# before it reaches its target. Directories come from \`\${link%/*}\`, which needs +# no helper at all. link="$0" +# \`\${link%/*}\` needs a separator to strip. A bare name came from a PATH lookup +# and stands for a file in the current directory. +case "$link" in + */*|*\\\\*) ;; + *) link="./$link" ;; +esac hops=0 while [ -L "$link" ] && [ "$hops" -lt 40 ]; do hops=$((hops+1)) - target=$(readlink "$link") + target=$(command -p readlink "$link") case "$target" in /*) link="$target" ;; - *) link="$(dirname "$link")/$target" ;; + *) link="\${link%/*}/$target" ;; esac done -basedir=$(dirname "$(echo "$link" | sed -e 's,\\\\,/,g')") +basedir=$(echo "$link" | command -p sed -e 's,\\\\,/,g') +basedir="\${basedir%/*}" basedir_win="$basedir" exe="" msys="" -case \`uname -a\` in +case \`command -p uname -a\` in *CYGWIN*|*MINGW*|*MSYS*) if command -v cygpath > /dev/null 2>&1; then basedir_win=\`cygpath -w "$basedir"\` @@ -919,22 +1036,35 @@ exports[`explicit shebang with args > shim files > sh.args.shim 1`] = ` #!/bin/sh # Resolve $0 through symlinks so basedir is the shim's real directory. # Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim. +# +# A shim runs with node_modules/.bin at the front of PATH, so readlink, sed, and +# uname go through \`command -p\`, which searches the system default path instead. +# A dependency's bin cannot stand in for one of them and take over the shim +# before it reaches its target. Directories come from \`\${link%/*}\`, which needs +# no helper at all. link="$0" +# \`\${link%/*}\` needs a separator to strip. A bare name came from a PATH lookup +# and stands for a file in the current directory. +case "$link" in + */*|*\\\\*) ;; + *) link="./$link" ;; +esac hops=0 while [ -L "$link" ] && [ "$hops" -lt 40 ]; do hops=$((hops+1)) - target=$(readlink "$link") + target=$(command -p readlink "$link") case "$target" in /*) link="$target" ;; - *) link="$(dirname "$link")/$target" ;; + *) link="\${link%/*}/$target" ;; esac done -basedir=$(dirname "$(echo "$link" | sed -e 's,\\\\,/,g')") +basedir=$(echo "$link" | command -p sed -e 's,\\\\,/,g') +basedir="\${basedir%/*}" basedir_win="$basedir" exe="" msys="" -case \`uname -a\` in +case \`command -p uname -a\` in *CYGWIN*|*MINGW*|*MSYS*) if command -v cygpath > /dev/null 2>&1; then basedir_win=\`cygpath -w "$basedir"\` @@ -1018,22 +1148,35 @@ exports[`explicit shebang with args, linking to another drive on Windows > shim #!/bin/sh # Resolve $0 through symlinks so basedir is the shim's real directory. # Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim. +# +# A shim runs with node_modules/.bin at the front of PATH, so readlink, sed, and +# uname go through \`command -p\`, which searches the system default path instead. +# A dependency's bin cannot stand in for one of them and take over the shim +# before it reaches its target. Directories come from \`\${link%/*}\`, which needs +# no helper at all. link="$0" +# \`\${link%/*}\` needs a separator to strip. A bare name came from a PATH lookup +# and stands for a file in the current directory. +case "$link" in + */*|*\\\\*) ;; + *) link="./$link" ;; +esac hops=0 while [ -L "$link" ] && [ "$hops" -lt 40 ]; do hops=$((hops+1)) - target=$(readlink "$link") + target=$(command -p readlink "$link") case "$target" in /*) link="$target" ;; - *) link="$(dirname "$link")/$target" ;; + *) link="\${link%/*}/$target" ;; esac done -basedir=$(dirname "$(echo "$link" | sed -e 's,\\\\,/,g')") +basedir=$(echo "$link" | command -p sed -e 's,\\\\,/,g') +basedir="\${basedir%/*}" basedir_win="$basedir" exe="" msys="" -case \`uname -a\` in +case \`command -p uname -a\` in *CYGWIN*|*MINGW*|*MSYS*) if command -v cygpath > /dev/null 2>&1; then basedir_win=\`cygpath -w "$basedir"\` @@ -1117,22 +1260,35 @@ exports[`explicit shebang with prog args > shim files > sh.args.shim 1`] = ` #!/bin/sh # Resolve $0 through symlinks so basedir is the shim's real directory. # Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim. +# +# A shim runs with node_modules/.bin at the front of PATH, so readlink, sed, and +# uname go through \`command -p\`, which searches the system default path instead. +# A dependency's bin cannot stand in for one of them and take over the shim +# before it reaches its target. Directories come from \`\${link%/*}\`, which needs +# no helper at all. link="$0" +# \`\${link%/*}\` needs a separator to strip. A bare name came from a PATH lookup +# and stands for a file in the current directory. +case "$link" in + */*|*\\\\*) ;; + *) link="./$link" ;; +esac hops=0 while [ -L "$link" ] && [ "$hops" -lt 40 ]; do hops=$((hops+1)) - target=$(readlink "$link") + target=$(command -p readlink "$link") case "$target" in /*) link="$target" ;; - *) link="$(dirname "$link")/$target" ;; + *) link="\${link%/*}/$target" ;; esac done -basedir=$(dirname "$(echo "$link" | sed -e 's,\\\\,/,g')") +basedir=$(echo "$link" | command -p sed -e 's,\\\\,/,g') +basedir="\${basedir%/*}" basedir_win="$basedir" exe="" msys="" -case \`uname -a\` in +case \`command -p uname -a\` in *CYGWIN*|*MINGW*|*MSYS*) if command -v cygpath > /dev/null 2>&1; then basedir_win=\`cygpath -w "$basedir"\` @@ -1216,22 +1372,35 @@ exports[`no cmd file > shim files > exe.shim 1`] = ` #!/bin/sh # Resolve $0 through symlinks so basedir is the shim's real directory. # Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim. +# +# A shim runs with node_modules/.bin at the front of PATH, so readlink, sed, and +# uname go through \`command -p\`, which searches the system default path instead. +# A dependency's bin cannot stand in for one of them and take over the shim +# before it reaches its target. Directories come from \`\${link%/*}\`, which needs +# no helper at all. link="$0" +# \`\${link%/*}\` needs a separator to strip. A bare name came from a PATH lookup +# and stands for a file in the current directory. +case "$link" in + */*|*\\\\*) ;; + *) link="./$link" ;; +esac hops=0 while [ -L "$link" ] && [ "$hops" -lt 40 ]; do hops=$((hops+1)) - target=$(readlink "$link") + target=$(command -p readlink "$link") case "$target" in /*) link="$target" ;; - *) link="$(dirname "$link")/$target" ;; + *) link="\${link%/*}/$target" ;; esac done -basedir=$(dirname "$(echo "$link" | sed -e 's,\\\\,/,g')") +basedir=$(echo "$link" | command -p sed -e 's,\\\\,/,g') +basedir="\${basedir%/*}" basedir_win="$basedir" exe="" msys="" -case \`uname -a\` in +case \`command -p uname -a\` in *CYGWIN*|*MINGW*|*MSYS*) if command -v cygpath > /dev/null 2>&1; then basedir_win=\`cygpath -w "$basedir"\` @@ -1282,22 +1451,35 @@ exports[`no shebang > shim files > exe.shim 1`] = ` #!/bin/sh # Resolve $0 through symlinks so basedir is the shim's real directory. # Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim. +# +# A shim runs with node_modules/.bin at the front of PATH, so readlink, sed, and +# uname go through \`command -p\`, which searches the system default path instead. +# A dependency's bin cannot stand in for one of them and take over the shim +# before it reaches its target. Directories come from \`\${link%/*}\`, which needs +# no helper at all. link="$0" +# \`\${link%/*}\` needs a separator to strip. A bare name came from a PATH lookup +# and stands for a file in the current directory. +case "$link" in + */*|*\\\\*) ;; + *) link="./$link" ;; +esac hops=0 while [ -L "$link" ] && [ "$hops" -lt 40 ]; do hops=$((hops+1)) - target=$(readlink "$link") + target=$(command -p readlink "$link") case "$target" in /*) link="$target" ;; - *) link="$(dirname "$link")/$target" ;; + *) link="\${link%/*}/$target" ;; esac done -basedir=$(dirname "$(echo "$link" | sed -e 's,\\\\,/,g')") +basedir=$(echo "$link" | command -p sed -e 's,\\\\,/,g') +basedir="\${basedir%/*}" basedir_win="$basedir" exe="" msys="" -case \`uname -a\` in +case \`command -p uname -a\` in *CYGWIN*|*MINGW*|*MSYS*) if command -v cygpath > /dev/null 2>&1; then basedir_win=\`cygpath -w "$basedir"\` @@ -1355,22 +1537,35 @@ exports[`shebang with -S > shim files > from.env.s.shim 1`] = ` #!/bin/sh # Resolve $0 through symlinks so basedir is the shim's real directory. # Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim. +# +# A shim runs with node_modules/.bin at the front of PATH, so readlink, sed, and +# uname go through \`command -p\`, which searches the system default path instead. +# A dependency's bin cannot stand in for one of them and take over the shim +# before it reaches its target. Directories come from \`\${link%/*}\`, which needs +# no helper at all. link="$0" +# \`\${link%/*}\` needs a separator to strip. A bare name came from a PATH lookup +# and stands for a file in the current directory. +case "$link" in + */*|*\\\\*) ;; + *) link="./$link" ;; +esac hops=0 while [ -L "$link" ] && [ "$hops" -lt 40 ]; do hops=$((hops+1)) - target=$(readlink "$link") + target=$(command -p readlink "$link") case "$target" in /*) link="$target" ;; - *) link="$(dirname "$link")/$target" ;; + *) link="\${link%/*}/$target" ;; esac done -basedir=$(dirname "$(echo "$link" | sed -e 's,\\\\,/,g')") +basedir=$(echo "$link" | command -p sed -e 's,\\\\,/,g') +basedir="\${basedir%/*}" basedir_win="$basedir" exe="" msys="" -case \`uname -a\` in +case \`command -p uname -a\` in *CYGWIN*|*MINGW*|*MSYS*) if command -v cygpath > /dev/null 2>&1; then basedir_win=\`cygpath -w "$basedir"\` From 7f244cb6ffd441479cce38e14b34bd819c8fdc0a Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Sat, 12 Sep 2026 02:40:54 +0200 Subject: [PATCH 2/2] test: run the shim as a bare name handed to sh The kernel and execvp hand the interpreter the path they resolved, so `$0` is bare only when a shell is given the name itself. Cover that branch of the header directly instead of through a PATH lookup, which cannot reach it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01W5f2VB7CDEj1hu1SEy5chh --- test/e2e.test.js | 58 +++++++++++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/test/e2e.test.js b/test/e2e.test.js index 366ff69..a3c9f8c 100644 --- a/test/e2e.test.js +++ b/test/e2e.test.js @@ -209,6 +209,25 @@ describeOnPosix('sh shim resolves its helpers off the caller\'s PATH', () => { fs.chmodSync(file, 0o755) } + // A shimmed tool plus a relative symlink to it in the same directory, so the + // walk composes a directory with the link target instead of taking one + // straight from readlink. + const makeShimmedTool = async (tempDir) => { + const binDir = path.join(tempDir, 'node_modules', '.bin') + const target = path.join(tempDir, 'node_modules', 'typescript', 'bin', 'tsc.js') + fs.mkdirSync(binDir, { recursive: true }) + fs.mkdirSync(path.dirname(target), { recursive: true }) + fs.writeFileSync(target, 'console.log("tsc-output")\n', 'utf8') + // A dependency can declare a bin named node.exe, and the shim's basedir is + // the directory those bins land in. Only a lying uname reaches it. + writeExecutable(path.join(binDir, 'node.exe'), '#!/bin/sh\necho hijacked\n') + + const shim = path.join(binDir, 'tsc') + await cmdShim(target, shim, { createCmdFile: false }) + fs.symlinkSync('tsc', path.join(binDir, 'tsc-link')) + return binDir + } + // Write the tree the decoys point at, and the decoys, returning the directory // to put at the front of PATH. Each decoy answers with what its real // counterpart would be asked for, so any one of them alone is enough to @@ -232,26 +251,10 @@ describeOnPosix('sh shim resolves its helpers off the caller\'s PATH', () => { return decoyDir } - test('reaches its target with decoy readlink, dirname, sed, and uname first on PATH', async () => { - const tempDir = tempy.directory() - const binDir = path.join(tempDir, 'node_modules', '.bin') - const target = path.join(tempDir, 'node_modules', 'typescript', 'bin', 'tsc.js') - fs.mkdirSync(binDir, { recursive: true }) - fs.mkdirSync(path.dirname(target), { recursive: true }) - fs.writeFileSync(target, 'console.log("tsc-output")\n', 'utf8') - // A dependency can declare a bin named node.exe, and the shim's basedir is - // the directory those bins land in. Only a lying uname reaches it. - writeExecutable(path.join(binDir, 'node.exe'), '#!/bin/sh\necho hijacked\n') - - const shim = path.join(binDir, 'tsc') - await cmdShim(target, shim, { createCmdFile: false }) - // Relative and in the shim's own directory, so the walk composes a - // directory with the link target instead of taking one from readlink. - const link = path.join(binDir, 'tsc-link') - fs.symlinkSync('tsc', link) - + const runWithDecoys = (tempDir, cmd, args, cwd) => { const decoyDir = plantHijackTreeAndDecoys(tempDir) - const r = spawnSync(link, { + const r = spawnSync(cmd, args, { + cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: { @@ -259,8 +262,23 @@ describeOnPosix('sh shim resolves its helpers off the caller\'s PATH', () => { PATH: [decoyDir, path.dirname(process.execPath), process.env.PATH].join(path.delimiter), }, }) - assert.equal(r.status, 0, `shim exited ${r.status}\nstdout: ${r.stdout}\nstderr: ${r.stderr}`) assert.equal(r.stdout.trim(), 'tsc-output', 'the shim took a helper from the caller\'s PATH') + } + + test('reaches its target with decoy readlink, dirname, sed, and uname first on PATH', async () => { + const tempDir = tempy.directory() + const binDir = await makeShimmedTool(tempDir) + + runWithDecoys(tempDir, path.join(binDir, 'tsc-link'), []) + }) + + // The kernel and execvp hand the interpreter the path they resolved, so $0 is + // bare only when a shell is given the name itself. + test('reaches its target when sh receives a bare name', async () => { + const tempDir = tempy.directory() + const binDir = await makeShimmedTool(tempDir) + + runWithDecoys(tempDir, 'sh', ['tsc-link'], binDir) }) })