Skip to content

Commit 2ab74b0

Browse files
feat: strip patchedDependencies from the packed package.json (#497)
Part of native dependency patching ([npm/rfcs#862](npm/rfcs#862)). When packing a `directory` spec (the `npm publish` / `npm pack` path), this strips a top-level `patchedDependencies` field from the `package.json` written **into the tarball**. ## Why `patchedDependencies` declares project-local patches against installed dependencies. It is honored only in a root manifest, so it is meaningless to consumers of a published package and should never travel through the registry. The published *packument* manifest is already stripped in `libnpmpublish`; this closes the other half — the `package.json` inside the tarball itself — so `npm pack --dry-run` and the published tarball no longer carry the field. It pairs with the npm-packlist change that excludes the patch files themselves; together they guarantee a patched project publishes clean. ## How `DirFetcher` packs the raw on-disk files via `tar.c`, so the tarball's `package.json` is the literal file on disk — there is no manifest seam to edit. The new `#tarOptions()`: 1. Reads the on-disk `package.json` (after `prepare`) via `@npmcli/package-json`. If it has no `patchedDependencies`, returns the existing options unchanged — **non-patched packs are byte-for-byte identical to before**. 2. Otherwise deletes the field and re-serializes preserving the original indent, newline, and key order (the indent/newline symbols `@npmcli/package-json` attaches; `JSON.stringify` ignores them), writes the stripped copy to a temp dir, and removes the temp dir if the write fails. 3. Sets node-tar's `onWriteEntry` to redirect **only** the top-level `package.json` entry's `absolute` at the stripped copy and fix its `stat.size`/`nlink`. `onWriteEntry` runs before the header and the file's hardlink check, so the override is honored; every other file is untouched. 4. The temp dir is removed once the tar source stream emits `end`/`error`, so it outlives content consumption. No behavior change for any package without `patchedDependencies`. ## References Part of - npm/rfcs#862 Related to - npm/cli#9439 - npm/npm-packlist#291
1 parent 1f5f131 commit 2ab74b0

2 files changed

Lines changed: 117 additions & 3 deletions

File tree

lib/dir.js

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
const { resolve } = require('node:path')
1+
const { resolve, join } = require('node:path')
2+
const { mkdtemp, writeFile, rm } = require('node:fs/promises')
3+
const { tmpdir } = require('node:os')
24
const packlist = require('npm-packlist')
35
const runScript = require('@npmcli/run-script')
46
const tar = require('tar')
57
const { Minipass } = require('minipass')
8+
const PackageJson = require('@npmcli/package-json')
69
const Fetcher = require('./fetcher.js')
710
const FileFetcher = require('./file.js')
811
const _ = require('./util/protected.js')
@@ -78,12 +81,64 @@ class DirFetcher extends Fetcher {
7881
}
7982
return packlist(this.tree, { path: this.resolved, prefix, workspaces, globalIgnoreFile })
8083
})
81-
.then(files => tar.c(tarCreateOptions(this.package), files)
82-
.on('error', er => stream.emit('error', er)).pipe(stream))
84+
.then(async files => {
85+
const { options, cleanup } = await this.#tarOptions()
86+
const source = tar.c(options, files)
87+
// the strip temp file must outlive content consumption, so clean up once the stream is done
88+
source.once('end', cleanup)
89+
source.once('error', cleanup)
90+
return source.on('error', er => stream.emit('error', er)).pipe(stream)
91+
})
8392
.catch(er => stream.emit('error', er))
8493
return stream
8594
}
8695

96+
// Build the tar create options.
97+
// When the packed package.json declares patchedDependencies, redirect it to a stripped copy so project-local patches never ship.
98+
// Non-patched packs are unchanged.
99+
async #tarOptions () {
100+
const options = tarCreateOptions(this.package)
101+
102+
// read package.json from disk after prepare so the strip reflects the actually-packed manifest.
103+
const pkgJson = await PackageJson.load(this.resolved)
104+
if (!('patchedDependencies' in pkgJson.content)) {
105+
return { options, cleanup: () => {} }
106+
}
107+
108+
// serialize the package.json minus patchedDependencies, preserving its indent and newline.
109+
// JSON.stringify ignores the indent and newline symbols @npmcli/package-json attaches to content.
110+
delete pkgJson.content.patchedDependencies
111+
const { content } = pkgJson
112+
const indent = content[Symbol.for('indent')]
113+
const newline = content[Symbol.for('newline')]
114+
const stripped = `${JSON.stringify(content, null, indent)}\n`.replace(/\n/g, newline)
115+
116+
// write the stripped copy to a temp dir, removing it if the write itself fails.
117+
const dir = await mkdtemp(join(tmpdir(), 'pacote-pack-'))
118+
const strippedPath = join(dir, 'package.json')
119+
try {
120+
await writeFile(strippedPath, stripped)
121+
} catch (er) {
122+
/* istanbul ignore next - writing to a freshly created temp dir is not deterministically failable */
123+
await rm(dir, { recursive: true, force: true })
124+
/* istanbul ignore next */
125+
throw er
126+
}
127+
const size = Buffer.byteLength(stripped)
128+
129+
// point only the top-level package.json entry at the stripped copy; every other file is untouched.
130+
// onWriteEntry runs before the tar header and the file's hardlink check, so size and nlink here are honored.
131+
options.onWriteEntry = (entry) => {
132+
if (entry.path === 'package.json') {
133+
entry.absolute = strippedPath
134+
entry.stat.size = size
135+
entry.stat.nlink = 1
136+
}
137+
}
138+
139+
return { options, cleanup: () => rm(dir, { recursive: true, force: true }) }
140+
}
141+
87142
manifest () {
88143
if (this.package) {
89144
return Promise.resolve(this.package)

test/dir-patched-dependencies.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
const t = require('tap')
2+
const Arborist = require('@npmcli/arborist')
3+
const fs = require('node:fs')
4+
const { resolve } = require('node:path')
5+
const DirFetcher = require('../lib/dir.js')
6+
7+
const loadActual = (path) => new Arborist({ path }).loadActual()
8+
const me = t.testdir()
9+
10+
t.test('strips patchedDependencies from the packed package.json, preserving formatting', async t => {
11+
const original = JSON.stringify({
12+
name: 'patched-pkg',
13+
version: '1.0.0',
14+
main: 'index.js',
15+
patchedDependencies: { 'abbrev@2.0.0': 'patches/abbrev@2.0.0.patch' },
16+
license: 'MIT',
17+
}, null, 2) + '\n'
18+
const dir = t.testdir({
19+
'package.json': original,
20+
'index.js': 'module.exports = 1\n',
21+
patches: { 'abbrev@2.0.0.patch': 'the patch\n' },
22+
})
23+
const f = new DirFetcher(`file:${dir}`, { tree: await loadActual(dir) })
24+
const out = resolve(me, 'patched')
25+
await f.extract(out)
26+
27+
const packed = fs.readFileSync(resolve(out, 'package.json'), 'utf8')
28+
t.notMatch(packed, /patchedDependencies/, 'patchedDependencies stripped from the tarball')
29+
const expected = JSON.stringify({
30+
name: 'patched-pkg', version: '1.0.0', main: 'index.js', license: 'MIT',
31+
}, null, 2) + '\n'
32+
t.equal(packed, expected, 'other fields and the 2-space formatting are preserved')
33+
// the source package.json on disk is never mutated
34+
t.equal(fs.readFileSync(resolve(dir, 'package.json'), 'utf8'), original, 'source package.json untouched')
35+
})
36+
37+
t.test('leaves a package without patchedDependencies byte-identical', async t => {
38+
const original = '{\n "name": "plain",\n "version": "1.0.0",\n "main": "index.js"\n}\n'
39+
const dir = t.testdir({ 'package.json': original, 'index.js': 'x\n' })
40+
const f = new DirFetcher(`file:${dir}`, { tree: await loadActual(dir) })
41+
const out = resolve(me, 'plain')
42+
await f.extract(out)
43+
t.equal(fs.readFileSync(resolve(out, 'package.json'), 'utf8'), original, 'unchanged when no patches are declared')
44+
})
45+
46+
t.test('falls back to default formatting for a minified package.json', async t => {
47+
const dir = t.testdir({
48+
'package.json':
49+
'{"name":"min","version":"1.0.0","patchedDependencies":{"abbrev@2.0.0":"patches/a.patch"}}',
50+
'index.js': 'x\n',
51+
patches: { 'a.patch': 'p\n' },
52+
})
53+
const f = new DirFetcher(`file:${dir}`, { tree: await loadActual(dir) })
54+
const out = resolve(me, 'min')
55+
await f.extract(out)
56+
const packed = fs.readFileSync(resolve(out, 'package.json'), 'utf8')
57+
t.notMatch(packed, /patchedDependencies/, 'patchedDependencies stripped from a minified manifest')
58+
t.match(packed, /"name":\s*"min"/, 'the rest of the manifest still ships')
59+
})

0 commit comments

Comments
 (0)