Skip to content

Commit 4f84f56

Browse files
committed
fix(arborist): harden .npm-extension validation, staleness probe, and workspace warning
1 parent a95f9ca commit 4f84f56

5 files changed

Lines changed: 133 additions & 27 deletions

File tree

workspaces/arborist/lib/arborist/build-ideal-tree.js

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const { isReleaseAgeExcluded, trustedSpecName } = require('../release-age-exclud
2828
const { resolvePatchedDependencies } = require('../patched-dependencies.js')
2929
const PackageExtensions = require('../package-extensions.js')
3030
const NpmExtension = require('../npm-extension.js')
31+
const { hasExtensionFile } = require('../npm-extension.js')
3132
const Shrinkwrap = require('../shrinkwrap.js')
3233
const { defaultLockfileVersion } = Shrinkwrap
3334
const Node = require('../node.js')
@@ -193,6 +194,7 @@ module.exports = cls => class IdealTreeBuilder extends cls {
193194
rm: options.rm || [],
194195
})
195196
this.#warnWorkspacePackageExtensions()
197+
this.#warnWorkspaceNpmExtension()
196198
} finally {
197199
timeEnd()
198200
this.finishTracker('idealTree')
@@ -285,8 +287,9 @@ module.exports = cls => class IdealTreeBuilder extends cls {
285287
// The selected file's hash is stashed on the lockfile meta so commit() can persist it and npm ci can detect stale extension state.
286288
async #loadNpmExtension () {
287289
const lockedHash = this.idealTree.meta.npmExtensionHash
290+
// ignore-extension (and, via flatten, ignore-scripts) disables discovery and execution.
291+
// Leave any locked extension state untouched so npm ci reifies the locked graph as-is and the lockfile stays internally consistent.
288292
if (this.options.ignoreExtension) {
289-
this.idealTree.meta.npmExtensionHash = null
290293
return
291294
}
292295
const ext = new NpmExtension({
@@ -295,20 +298,22 @@ module.exports = cls => class IdealTreeBuilder extends cls {
295298
})
296299
this.#npmExtension = ext
297300
this.idealTree.meta.npmExtensionHash = ext.hash
298-
if (!ext.present) {
299-
return
301+
if (ext.present) {
302+
await ext.load()
300303
}
301-
await ext.load()
302304

303305
// When the extension file changed since the lockfile was written, locked manifests may no longer reflect its output.
304-
// Arbitrary code has no selector to predict which packages it affects, so re-resolve any node that carried old provenance or that the new file now transforms.
306+
// This also covers removal: a now-absent file hashes to null, which differs from the locked hash and reverts the previously transformed nodes.
307+
// Arbitrary code has no selector to predict which packages it affects, so re-resolve any node that carried old provenance or that the current file now transforms.
305308
if (this.idealTree.meta.loadedFromDisk && lockedHash !== ext.hash) {
306309
for (const node of [...this.idealTree.inventory.values()]) {
307310
if (node.isProjectRoot || node.isWorkspace || node.isTop || node.isLink) {
308311
continue
309312
}
310-
// a node with old provenance carries an already-transformed manifest, so refetch; otherwise its locked manifest is the raw baseline the new file can be tried against
311-
const affected = node.npmExtensionApplied || ext.apply(node.package)
313+
// A node with old provenance carries an already-transformed manifest, so always refetch it.
314+
// Otherwise probe the locked manifest (without caching, so the authoritative full-manifest fetch is not pre-seeded).
315+
// The locked manifest carries every resolution-relevant field (name, version, dependencies, peer metadata); a transform that newly targets a node by a field not persisted in the lockfile is the documented edge that may need a manual re-lock.
316+
const affected = node.npmExtensionApplied || ext.apply(node.package, { memoize: false })
312317
if (affected) {
313318
for (const edge of node.edgesIn) {
314319
this.#depsQueue.push(edge.from)
@@ -348,6 +353,20 @@ module.exports = cls => class IdealTreeBuilder extends cls {
348353
}
349354
}
350355

356+
// Warn when a non-root workspace package contains a .npm-extension file; only the workspace root's file is honored.
357+
#warnWorkspaceNpmExtension () {
358+
for (const node of this.idealTree.inventory.values()) {
359+
// a workspace is in the inventory as both a Link and its target node; warn once by skipping the link
360+
if (!node.isWorkspace || node.isLink || node.isProjectRoot) {
361+
continue
362+
}
363+
if (hasExtensionFile(node.realpath)) {
364+
log.warn('npm-extension',
365+
`".npm-extension" in workspace ${node.name} is ignored; it is only honored at the workspace root`)
366+
}
367+
}
368+
}
369+
351370
#parseSettings (options) {
352371
const update = options.update === true ? { all: true }
353372
: Array.isArray(options.update) ? { names: options.update }

workspaces/arborist/lib/arborist/load-actual.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,9 @@ module.exports = cls => class ActualLoader extends cls {
175175
await Promise.all(promises)
176176
}
177177

178-
this.#applyPackageExtensions()
178+
// .npm-extension runs before packageExtensions, matching the ideal-tree resolution order
179179
await this.#applyNpmExtension()
180+
this.#applyPackageExtensions()
180181

181182
if (!ignoreMissing) {
182183
await this.#findMissingEdges()

workspaces/arborist/lib/npm-extension.js

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// This module discovers and hashes the root extension file, loads its `transformManifest` export, and applies it to a deeply isolated manifest copy, returning an extended manifest plus minimal provenance.
44
// It never mutates the input manifest or any shared cache object.
55
const { resolve, sep } = require('node:path')
6-
const { readFileSync } = require('node:fs')
6+
const { readFileSync, existsSync } = require('node:fs')
77
const { pathToFileURL } = require('node:url')
88
const { isDeepStrictEqual } = require('node:util')
99
const { log } = require('proc-log')
@@ -102,7 +102,7 @@ class NpmExtension {
102102
if (this.format === 'mjs') {
103103
mod = await import(`${pathToFileURL(this.path).href}?h=${this.hash}`)
104104
} else {
105-
delete require.cache[this.path]
105+
delete require.cache[require.resolve(this.path)]
106106
mod = require(this.path)
107107
}
108108
const transform = mod?.transformManifest ?? mod?.default?.transformManifest
@@ -114,16 +114,22 @@ class NpmExtension {
114114

115115
// Apply transformManifest to a candidate manifest, returning { pkg, applied } or null when nothing changed.
116116
// Results are cached once per resolved package identity; consumers get a deeply isolated copy so they cannot mutate the cached effective manifest.
117-
apply (pkg) {
117+
// Pass { memoize: false } to run without caching, e.g. a staleness probe over partial lockfile manifests that must not seed the cache used by full-manifest fetches.
118+
apply (pkg, { memoize = true } = {}) {
118119
if (!this.#transform || !pkg?.name) {
119120
return null
120121
}
121122
const key = this.#identity(pkg)
122-
if (!this.#cache.has(key)) {
123-
this.#cache.set(key, this.#run(pkg))
123+
let result
124+
if (this.#cache.has(key)) {
125+
result = this.#cache.get(key)
126+
} else {
127+
result = this.#run(pkg)
128+
if (memoize) {
129+
this.#cache.set(key, result)
130+
}
124131
}
125-
const result = this.#cache.get(key)
126-
return result && { pkg: structuredClone(result.pkg), applied: result.applied }
132+
return result && { pkg: structuredClone(result.pkg), applied: structuredClone(result.applied) }
127133
}
128134

129135
// Identity key for the transform cache: package integrity when available, otherwise resolved source plus name and version.
@@ -157,32 +163,49 @@ class NpmExtension {
157163
`.npm-extension transformManifest must return a manifest object for ${pkg.name}@${pkg.version}`,
158164
'ENPMEXTENSIONRETURN', { pkgid: `${pkg.name}@${pkg.version}` })
159165
}
160-
// Only dependency and peer fields may change; any other altered field is rejected so manifest contents stay authoritative.
161-
for (const k of new Set([...Object.keys(pkg), ...Object.keys(returned)])) {
166+
// Only dependency and peer fields may change; any other field the returned manifest explicitly alters is rejected.
167+
// Fields the returned object omits are left untouched, so a handler may return a new object with only the fields it repairs.
168+
for (const k of Object.keys(returned)) {
162169
if (!EXTENSION_FIELDS.includes(k) && !isDeepStrictEqual(returned[k], pkg[k])) {
163170
throw err(
164171
`.npm-extension transformManifest changed unsupported field "${k}" on ${pkg.name}@${pkg.version}; only ${EXTENSION_FIELDS.join(', ')} may change`,
165172
'ENPMEXTENSIONFIELD', { pkgid: `${pkg.name}@${pkg.version}`, field: k })
166173
}
167174
}
168-
// Build the effective manifest from the original baseline plus the returned allowlisted fields, honoring deletion when a field is omitted.
175+
// Build the effective manifest from the normalized baseline plus the returned allowlisted fields.
176+
// A field the handler omits is left as the baseline; delete individual entries by returning a field object without them.
169177
const next = { ...pkg }
170178
for (const field of EXTENSION_FIELDS) {
171179
if (returned[field] === undefined) {
172-
delete next[field]
173180
continue
174181
}
175-
if (typeof returned[field] !== 'object' || Array.isArray(returned[field])) {
176-
throw err(
177-
`.npm-extension transformManifest set ${field} to a non-object on ${pkg.name}@${pkg.version}`,
178-
'ENPMEXTENSIONVALUE', { pkgid: `${pkg.name}@${pkg.version}`, field })
179-
}
182+
this.#validateField(field, returned[field], pkg)
180183
next[field] = returned[field]
181184
}
182185
const applied = this.#provenance(pkg, next)
183186
return applied && { pkg: next, applied }
184187
}
185188

189+
// Validate a returned allowlisted field and its entries, so invalid output fails with .npm-extension and package context.
190+
// Dependency maps hold version strings; peerDependenciesMeta holds metadata objects.
191+
#validateField (field, value, pkg) {
192+
const fail = (suffix) => err(
193+
`.npm-extension transformManifest set ${suffix} to an invalid value on ${pkg.name}@${pkg.version}`,
194+
'ENPMEXTENSIONVALUE', { pkgid: `${pkg.name}@${pkg.version}`, field })
195+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
196+
throw fail(field)
197+
}
198+
for (const [name, entry] of Object.entries(value)) {
199+
if (field === 'peerDependenciesMeta') {
200+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
201+
throw fail(`${field}.${name}`)
202+
}
203+
} else if (typeof entry !== 'string') {
204+
throw fail(`${field}.${name}`)
205+
}
206+
}
207+
}
208+
186209
// Minimal provenance: the extension point plus, for each changed allowlisted field, a sorted array of affected dependency names.
187210
// Returns null when nothing changed.
188211
#provenance (before, after) {
@@ -203,8 +226,12 @@ class NpmExtension {
203226
}
204227
}
205228

229+
// Whether a directory contains any default .npm-extension file; a non-throwing existence check used for non-root workspace warnings.
230+
const hasExtensionFile = dir => FORMATS.some(f => existsSync(resolve(dir, f.file)))
231+
206232
module.exports = NpmExtension
207233
module.exports.NpmExtension = NpmExtension
208234
module.exports.discover = discover
235+
module.exports.hasExtensionFile = hasExtensionFile
209236
module.exports.hashFile = hashFile
210237
module.exports.EXTENSION_POINT = EXTENSION_POINT

workspaces/arborist/test/arborist/reify-npm-extension.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,27 @@ t.test('ignore-extension disables the transform and records no state', async t =
179179
t.notOk(lock.packages['node_modules/foo'].dependencies, 'foo has no extension-added dependency')
180180
})
181181

182+
t.test('warns when a non-root workspace contains a .npm-extension file', async t => {
183+
const warnings = []
184+
const onlog = (...m) => m[0] === 'warn' && m[1] === 'npm-extension' && warnings.push(m[2])
185+
process.on('log', onlog)
186+
t.teardown(() => process.removeListener('log', onlog))
187+
188+
const dir = t.testdir({
189+
'package.json': JSON.stringify({ name: 'root', workspaces: ['packages/*'] }),
190+
packages: {
191+
a: {
192+
'package.json': JSON.stringify({ name: 'a', version: '1.0.0' }),
193+
'.npm-extension.cjs': 'module.exports = { transformManifest (p) { return p } }\n',
194+
},
195+
b: { 'package.json': JSON.stringify({ name: 'b', version: '1.0.0' }) },
196+
},
197+
})
198+
await newArb(dir).buildIdealTree()
199+
t.match(warnings.join('\n'), /"\.npm-extension" in workspace a is ignored/, 'warns for the workspace with the file')
200+
t.notMatch(warnings.join('\n'), /workspace b/, 'does not warn for the workspace without one')
201+
})
202+
182203
t.test('a project with no .npm-extension installs normally and records no state', async t => {
183204
const dir = t.testdir({
184205
'package.json': JSON.stringify({ name: 'root', dependencies: { foo: '1.0.0' } }),
@@ -242,3 +263,18 @@ t.test('changing the extension file re-resolves affected packages', async t => {
242263
t.notOk(lock.packages['node_modules/bar'], 'bar removed after the extension stopped adding it')
243264
t.notOk(lock.packages['node_modules/foo']?.npmExtensionApplied, 'provenance cleared')
244265
})
266+
267+
t.test('removing the extension file reverts the locked graph', async t => {
268+
const dir = await setup(t)
269+
await newArb(dir).reify()
270+
t.ok(readLock(dir).packages['node_modules/bar'], 'bar installed by the extension')
271+
272+
// delete the extension file entirely, then reinstall; the transform-added edge and state must be reverted
273+
fs.rmSync(join(dir, '.npm-extension.cjs'))
274+
await register(t, dir, { withBar: false })
275+
await newArb(dir).reify()
276+
const lock = readLock(dir)
277+
t.notOk(lock.packages['node_modules/bar'], 'bar removed once the extension file is gone')
278+
t.notOk(lock.packages[''].npmExtensionHash, 'root hash cleared')
279+
t.notOk(lock.packages['node_modules/foo']?.npmExtensionApplied, 'foo provenance cleared')
280+
})

workspaces/arborist/test/npm-extension.js

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,19 +139,17 @@ t.test('apply: no-op transform returns null', async t => {
139139
})
140140

141141
t.test('apply: caches per identity and isolates consumers', async t => {
142-
let calls = 0
143142
const dir = t.testdir({
144143
'.npm-extension.cjs': cjs(`pkg.dependencies = { ...pkg.dependencies, added: '1.0.0' }; return pkg`),
145144
})
146145
const ext = new NpmExtension({ root: dir })
147146
await ext.load()
148-
// count executions by wrapping log via a side-effect file is overkill; instead rely on identity cache returning copies
147+
// two consumers of the same cached identity must each get a distinct, deeply isolated object
149148
const a = ext.apply({ name: 'foo', version: '1.0.0', _integrity: 'sha512-shared' })
150149
const b = ext.apply({ name: 'foo', version: '1.0.0', _integrity: 'sha512-shared' })
151150
t.not(a.pkg, b.pkg, 'each consumer gets a distinct object')
152151
a.pkg.dependencies.added = 'mutated'
153152
t.equal(b.pkg.dependencies.added, '1.0.0', 'mutating one copy does not affect another')
154-
void calls
155153
})
156154

157155
t.test('apply: rejects invalid transform output', async t => {
@@ -161,7 +159,11 @@ t.test('apply: rejects invalid transform output', async t => {
161159
['return []', 'ENPMEXTENSIONRETURN'],
162160
['return Promise.resolve(pkg)', 'ENPMEXTENSIONRETURN'],
163161
[`pkg.scripts = { build: 'x' }; return pkg`, 'ENPMEXTENSIONFIELD'],
162+
[`return { name: pkg.name, version: pkg.version, scripts: { build: 'x' } }`, 'ENPMEXTENSIONFIELD'],
164163
[`pkg.dependencies = 'nope'; return pkg`, 'ENPMEXTENSIONVALUE'],
164+
[`pkg.dependencies = null; return pkg`, 'ENPMEXTENSIONVALUE'],
165+
[`pkg.dependencies = { x: null }; return pkg`, 'ENPMEXTENSIONVALUE'],
166+
[`pkg.peerDependencies = { p: '*' }; pkg.peerDependenciesMeta = { p: null }; return pkg`, 'ENPMEXTENSIONVALUE'],
165167
[`throw new Error('boom')`, 'ENPMEXTENSIONTHROW'],
166168
]
167169
// a unique dir per case so require/import cache never serves a previous module
@@ -173,6 +175,27 @@ t.test('apply: rejects invalid transform output', async t => {
173175
}
174176
})
175177

178+
t.test('apply: a handler may return a new object with only repaired fields', async t => {
179+
const dir = t.testdir({
180+
'.npm-extension.cjs': cjs(`return { name: pkg.name, version: pkg.version, dependencies: { ...pkg.dependencies, bar: '^2.0.0' } }`),
181+
})
182+
const ext = new NpmExtension({ root: dir })
183+
await ext.load()
184+
const res = ext.apply({ name: 'foo', version: '1.0.0', dependencies: { keep: '1' }, scripts: { build: 'x' }, _integrity: 'sha512-z' })
185+
t.same(res.pkg.dependencies, { keep: '1', bar: '^2.0.0' }, 'returned dependencies overlaid on the baseline')
186+
t.same(res.pkg.scripts, { build: 'x' }, 'omitted non-allowlisted field preserved from the baseline')
187+
})
188+
189+
t.test('apply: isolates cached provenance between consumers', async t => {
190+
const dir = t.testdir({ '.npm-extension.cjs': cjs(`pkg.dependencies = { ...pkg.dependencies, bar: '1' }; return pkg`) })
191+
const ext = new NpmExtension({ root: dir })
192+
await ext.load()
193+
const a = ext.apply({ name: 'foo', version: '1.0.0', _integrity: 'sha512-prov' })
194+
const b = ext.apply({ name: 'foo', version: '1.0.0', _integrity: 'sha512-prov' })
195+
a.applied.dependencies.push('mutated')
196+
t.same(b.applied.dependencies, ['bar'], 'mutating one consumer\'s provenance does not affect another')
197+
})
198+
176199
t.test('apply: does not mutate the input manifest', async t => {
177200
const dir = t.testdir({ '.npm-extension.cjs': cjs(`pkg.dependencies = { bar: '1' }; return pkg`) })
178201
const ext = new NpmExtension({ root: dir })

0 commit comments

Comments
 (0)