Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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"\`
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
84 changes: 84 additions & 0 deletions test/e2e.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,87 @@ 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)
}

// 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
// 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
}

const runWithDecoys = (tempDir, cmd, args, cwd) => {
const decoyDir = plantHijackTreeAndDecoys(tempDir)
const r = spawnSync(cmd, args, {
cwd,
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')
}

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)
})
})
21 changes: 17 additions & 4 deletions test/e2e.test.js.snapshot
Original file line number Diff line number Diff line change
Expand Up @@ -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"\`
Expand Down
Loading
Loading