Skip to content
Open
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
6 changes: 4 additions & 2 deletions lib/commands/patch.js
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,9 @@ class Patch extends BaseCommand {
const id = ['-c', 'user.name=npm', '-c', 'user.email=npm@npmjs.com', '-c', 'commit.gpgsign=false']
await this.#git(['init', '-q', '-b', 'base'], repo)
// base = the version the patch was authored against
await cp(baseDir, repo, { recursive: true })
// never copy the tarball's own .git into the work repo: a planted
// .git/config or .git/hooks would run under the git commands below
await cp(baseDir, repo, { recursive: true, filter: notGitDir })
await this.#git(['add', '-Af'], repo)
await this.#git([...id, 'commit', '-qm', 'base', '--allow-empty'], repo)
// theirs = base + the existing patch, applied with the same jsdiff helper installs use
Expand All @@ -542,7 +544,7 @@ class Patch extends BaseCommand {
// ours = the new version, branched from base
await this.#git(['checkout', '-q', '-b', 'ours', 'base'], repo)
await this.#git(['rm', '-rqf', '.'], repo)
await cp(newDir, repo, { recursive: true })
await cp(newDir, repo, { recursive: true, filter: notGitDir })
await this.#git(['add', '-Af'], repo)
await this.#git([...id, 'commit', '-qm', 'ours', '--allow-empty'], repo)
// replay the patch's intent onto the new version
Expand Down
54 changes: 54 additions & 0 deletions test/lib/commands/patch.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const fs = require('node:fs')
const path = require('node:path')
const t = require('tap')
const { create: tarCreate } = require('tar')
const Arborist = require('@npmcli/arborist')
const pacote = require('pacote')

Expand Down Expand Up @@ -1326,3 +1327,56 @@ t.test('install honors --allow-unused-patches only from the cli', async t => {
await t.rejects(npm.exec('install', []), { code: 'EPATCHUNUSED' })
})
})

t.test('update does not run a dependency tarball\'s planted .git hooks', async t => {
// A published tarball can contain a `.git/` dir. `patch update` 3-way-merges
// in a throwaway git repo; copying the extracted tarball into that repo must
// drop `.git/`, or a planted hook (or core.fsmonitor in .git/config) runs
// under the `git add`/`git commit` that follow.
const name = 'upd-githook'
const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, {
config: { 'ignore-scripts': true, audit: false },
strictRegistryNock: false,
prefixDir: rootWith({ [name]: '^1.0.0' }),
})

const manifest = registry.manifest({ name, versions: ['1.0.0', '2.0.0'] })
// 1.0.0: clean baseline the user patches
const cleanDir = path.join(npm.prefix, `pkg-${name}-1.0.0`)
fs.mkdirSync(cleanDir, { recursive: true })
fs.writeFileSync(path.join(cleanDir, 'package.json'), JSON.stringify({ name, version: '1.0.0' }))
fs.writeFileSync(path.join(cleanDir, 'index.js'), 'a\nb\nc\n')
const cleanTar = await pacote.tarball(cleanDir, { Arborist })
registry.nock.get(new URL(manifest.versions['1.0.0'].dist.tarball).pathname)
.reply(200, cleanTar).persist()

// 2.0.0: a hand-built tarball that smuggles an executable pre-commit hook
const sentinel = path.join(npm.prefix, 'pwned.txt')
const malSrc = path.join(npm.prefix, 'mal-src')
fs.mkdirSync(path.join(malSrc, 'package', '.git', 'hooks'), { recursive: true })
fs.writeFileSync(path.join(malSrc, 'package', 'package.json'), JSON.stringify({ name, version: '2.0.0' }))
fs.writeFileSync(path.join(malSrc, 'package', 'index.js'), 'a\nb\nCC\n')
const hook = path.join(malSrc, 'package', '.git', 'hooks', 'pre-commit')
fs.writeFileSync(hook, `#!/bin/sh\necho pwned > "${sentinel}"\nexit 0\n`)
fs.chmodSync(hook, 0o755)
const malTar = await tarCreate({ gzip: true, cwd: malSrc, portable: true }, ['package']).concat()
registry.nock.get(new URL(manifest.versions['2.0.0'].dist.tarball).pathname)
.reply(200, malTar).persist()
registry.nock.get(`/${name}`).reply(200, manifest).persist()

await npm.exec('install', [])

outputs.length = 0
await npm.exec('patch', ['add', name])
const editDir = joinedOutput().match(/directory: (.+)/)[1].trim()
fs.writeFileSync(path.join(editDir, 'index.js'), 'AA\nb\nc\n')
await npm.exec('patch', ['commit', editDir])

npm.config.set('to', '2.0.0')
await npm.exec('patch', ['update', name])

t.notOk(fs.existsSync(sentinel), 'the planted git hook never ran')
const pkg = readJson(path.join(npm.prefix, 'package.json'))
t.same(pkg.patchedDependencies, { [`${name}@2.0.0`]: `patches/${name}@2.0.0.patch` },
'the update still rebased the patch onto the new version')
})
Loading