@npmcli/arborist
Advanced tools
| // Root-owned `.npm-extension.{mjs,cjs}`: an imperative `transformManifest(pkg, context)` extension point that repairs third-party manifests before Arborist reads a candidate's dependency edges. | ||
| // See RFC: https://github.com/npm/rfcs/pull/903 | ||
| // 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. | ||
| // It never mutates the input manifest or any shared cache object. | ||
| const { resolve, sep } = require('node:path') | ||
| const { readFileSync, existsSync } = require('node:fs') | ||
| const { pathToFileURL } = require('node:url') | ||
| const { isDeepStrictEqual } = require('node:util') | ||
| const { log } = require('proc-log') | ||
| const ssri = require('ssri') | ||
| const { EXTENSION_FIELDS } = require('./package-extensions.js') | ||
| const EXTENSION_POINT = 'transformManifest' | ||
| // The two supported module formats and their default discovery filenames. | ||
| const FORMATS = [ | ||
| { ext: 'mjs', file: '.npm-extension.mjs' }, | ||
| { ext: 'cjs', file: '.npm-extension.cjs' }, | ||
| ] | ||
| const err = (message, code, extra = {}) => | ||
| Object.assign(new Error(message), { code, ...extra }) | ||
| // Read a file's bytes, or null when it does not exist. | ||
| const readBytes = path => { | ||
| try { | ||
| return readFileSync(path) | ||
| } catch (e) { | ||
| if (e.code === 'ENOENT') { | ||
| return null | ||
| } | ||
| throw e | ||
| } | ||
| } | ||
| // Resolve which extension file to load, returning { path, format, bytes } or null when none is present. | ||
| // A configured `extension-file` wins over default discovery; it must resolve inside the project root and use a `.mjs` or `.cjs` extension. | ||
| // When both default files exist npm fails rather than choosing one implicitly. | ||
| const discover = (root, extensionFile) => { | ||
| if (extensionFile) { | ||
| const path = resolve(root, extensionFile) | ||
| if (path !== root && !path.startsWith(root + sep)) { | ||
| throw err(`extension-file "${extensionFile}" must resolve inside the project root`, 'ENPMEXTENSIONPATH') | ||
| } | ||
| const format = FORMATS.find(f => path.endsWith(`.${f.ext}`))?.ext | ||
| if (!format) { | ||
| throw err(`extension-file "${extensionFile}" must use a .mjs or .cjs extension`, 'ENPMEXTENSIONPATH') | ||
| } | ||
| const bytes = readBytes(path) | ||
| if (bytes === null) { | ||
| throw err(`extension-file "${extensionFile}" was not found`, 'ENPMEXTENSIONPATH') | ||
| } | ||
| return { path, format, bytes } | ||
| } | ||
| const found = FORMATS | ||
| .map(f => ({ path: resolve(root, f.file), format: f.ext, bytes: readBytes(resolve(root, f.file)) })) | ||
| .filter(f => f.bytes !== null) | ||
| if (found.length > 1) { | ||
| throw err('found both .npm-extension.mjs and .npm-extension.cjs; keep only one', 'ENPMEXTENSIONDUP') | ||
| } | ||
| return found[0] || null | ||
| } | ||
| // Hash the selected extension file: a format-tagged prefix plus the raw file bytes, using npm's lockfile digest encoding. | ||
| // The prefix keeps `.mjs` and `.cjs` files with identical bytes distinct, and excludes any path so the digest is machine-independent. | ||
| const hashFile = (format, bytes) => | ||
| ssri.fromData( | ||
| Buffer.concat([Buffer.from(`npm-extension:v1:${format}\n`), bytes]), | ||
| { algorithms: ['sha512'] } | ||
| ).toString() | ||
| class NpmExtension { | ||
| #cache = new Map() | ||
| #transform = null | ||
| constructor ({ root, extensionFile = null } = {}) { | ||
| this.root = root | ||
| this.path = null | ||
| this.format = null | ||
| this.hash = null | ||
| const selected = root ? discover(root, extensionFile) : null | ||
| if (!selected) { | ||
| this.present = false | ||
| return | ||
| } | ||
| this.present = true | ||
| this.path = selected.path | ||
| this.format = selected.format | ||
| this.hash = hashFile(selected.format, selected.bytes) | ||
| } | ||
| // Import the extension module and capture its `transformManifest` export. | ||
| // ESM files are loaded with dynamic import; CommonJS files with require. | ||
| // The export must be a function; anything else fails the install. | ||
| async load () { | ||
| if (!this.present) { | ||
| return | ||
| } | ||
| // Key the module load by the file hash so a changed file is reloaded rather than served stale from a module cache within one process. | ||
| let mod | ||
| if (this.format === 'mjs') { | ||
| mod = await import(`${pathToFileURL(this.path).href}?h=${this.hash}`) | ||
| } else { | ||
| delete require.cache[require.resolve(this.path)] | ||
| mod = require(this.path) | ||
| } | ||
| const transform = mod?.transformManifest ?? mod?.default?.transformManifest | ||
| if (typeof transform !== 'function') { | ||
| throw err(`.npm-extension must export a transformManifest function`, 'ENPMEXTENSIONSHAPE') | ||
| } | ||
| this.#transform = transform | ||
| } | ||
| // Apply transformManifest to a candidate manifest, returning { pkg, applied } or null when nothing changed. | ||
| // Results are cached once per resolved package identity; consumers get a deeply isolated copy so they cannot mutate the cached effective manifest. | ||
| // 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. | ||
| apply (pkg, { memoize = true } = {}) { | ||
| if (!this.#transform || !pkg?.name) { | ||
| return null | ||
| } | ||
| const key = this.#identity(pkg) | ||
| let result | ||
| if (this.#cache.has(key)) { | ||
| result = this.#cache.get(key) | ||
| } else { | ||
| result = this.#run(pkg) | ||
| if (memoize) { | ||
| this.#cache.set(key, result) | ||
| } | ||
| } | ||
| return result && { pkg: structuredClone(result.pkg), applied: structuredClone(result.applied) } | ||
| } | ||
| // Identity key for the transform cache: package integrity when available, otherwise resolved source plus name and version. | ||
| #identity (pkg) { | ||
| return pkg._integrity || `${pkg._resolved || ''}:${pkg.name}@${pkg.version || ''}` | ||
| } | ||
| // Run transformManifest on a deeply isolated copy and validate the result. | ||
| // Returns { pkg, applied } when dependency or peer metadata changed, or null when it did not. | ||
| #run (pkg) { | ||
| const context = { | ||
| log: message => log.silly('npm-extension', message), | ||
| root: this.root, | ||
| extensionPoint: EXTENSION_POINT, | ||
| } | ||
| let returned | ||
| try { | ||
| returned = this.#transform(structuredClone(pkg), context) | ||
| } catch (e) { | ||
| throw err( | ||
| `.npm-extension transformManifest threw while processing ${pkg.name}@${pkg.version}: ${e.message}`, | ||
| 'ENPMEXTENSIONTHROW', { pkgid: `${pkg.name}@${pkg.version}` }) | ||
| } | ||
| if (returned && typeof returned.then === 'function') { | ||
| throw err( | ||
| `.npm-extension transformManifest must return a manifest synchronously, not a promise, for ${pkg.name}@${pkg.version}`, | ||
| 'ENPMEXTENSIONRETURN', { pkgid: `${pkg.name}@${pkg.version}` }) | ||
| } | ||
| if (!returned || typeof returned !== 'object' || Array.isArray(returned)) { | ||
| throw err( | ||
| `.npm-extension transformManifest must return a manifest object for ${pkg.name}@${pkg.version}`, | ||
| 'ENPMEXTENSIONRETURN', { pkgid: `${pkg.name}@${pkg.version}` }) | ||
| } | ||
| // Only dependency and peer fields may change; any other field the returned manifest explicitly alters is rejected. | ||
| // Fields the returned object omits are left untouched, so a handler may return a new object with only the fields it repairs. | ||
| for (const k of Object.keys(returned)) { | ||
| if (!EXTENSION_FIELDS.includes(k) && !isDeepStrictEqual(returned[k], pkg[k])) { | ||
| throw err( | ||
| `.npm-extension transformManifest changed unsupported field "${k}" on ${pkg.name}@${pkg.version}; only ${EXTENSION_FIELDS.join(', ')} may change`, | ||
| 'ENPMEXTENSIONFIELD', { pkgid: `${pkg.name}@${pkg.version}`, field: k }) | ||
| } | ||
| } | ||
| // Build the effective manifest from the normalized baseline plus the returned allowlisted fields. | ||
| // A field the handler omits is left as the baseline; delete individual entries by returning a field object without them. | ||
| const next = { ...pkg } | ||
| for (const field of EXTENSION_FIELDS) { | ||
| if (returned[field] === undefined) { | ||
| continue | ||
| } | ||
| this.#validateField(field, returned[field], pkg) | ||
| next[field] = returned[field] | ||
| } | ||
| const applied = this.#provenance(pkg, next) | ||
| return applied && { pkg: next, applied } | ||
| } | ||
| // Validate a returned allowlisted field and its entries, so invalid output fails with .npm-extension and package context. | ||
| // Dependency maps hold version strings; peerDependenciesMeta holds metadata objects. | ||
| #validateField (field, value, pkg) { | ||
| const fail = (suffix) => err( | ||
| `.npm-extension transformManifest set ${suffix} to an invalid value on ${pkg.name}@${pkg.version}`, | ||
| 'ENPMEXTENSIONVALUE', { pkgid: `${pkg.name}@${pkg.version}`, field }) | ||
| if (value === null || typeof value !== 'object' || Array.isArray(value)) { | ||
| throw fail(field) | ||
| } | ||
| for (const [name, entry] of Object.entries(value)) { | ||
| if (field === 'peerDependenciesMeta') { | ||
| if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { | ||
| throw fail(`${field}.${name}`) | ||
| } | ||
| } else if (typeof entry !== 'string') { | ||
| throw fail(`${field}.${name}`) | ||
| } | ||
| } | ||
| } | ||
| // Minimal provenance: the extension point plus, for each changed allowlisted field, a sorted array of affected dependency names. | ||
| // Returns null when nothing changed. | ||
| #provenance (before, after) { | ||
| const applied = { extensionPoint: EXTENSION_POINT } | ||
| let changed = false | ||
| for (const field of EXTENSION_FIELDS) { | ||
| const b = before[field] || {} | ||
| const a = after[field] || {} | ||
| const names = [...new Set([...Object.keys(b), ...Object.keys(a)])] | ||
| .filter(n => !isDeepStrictEqual(b[n], a[n])) | ||
| .sort() | ||
| if (names.length) { | ||
| applied[field] = names | ||
| changed = true | ||
| } | ||
| } | ||
| return changed ? applied : null | ||
| } | ||
| } | ||
| // Whether a directory contains any default .npm-extension file; a non-throwing existence check used for non-root workspace warnings. | ||
| const hasExtensionFile = dir => FORMATS.some(f => existsSync(resolve(dir, f.file))) | ||
| module.exports = NpmExtension | ||
| module.exports.NpmExtension = NpmExtension | ||
| module.exports.discover = discover | ||
| module.exports.hasExtensionFile = hasExtensionFile | ||
| module.exports.hashFile = hashFile | ||
| module.exports.EXTENSION_POINT = EXTENSION_POINT |
@@ -231,3 +231,5 @@ const { join } = require('node:path') | ||
| // local `file:` deps (non-workspace fsChildren) should be treated as local dependencies, not external, so they get symlinked directly instead of being extracted into the store. | ||
| const isLocal = (n) => n.isWorkspace || node.fsChildren?.has(n) | ||
| // A file: dep surfaces as a Link edge whose resolved spec starts with file:; detect it from the edge so the target is treated as local even when it is absent from idealTree.fsChildren (a workspace consumer, or a target outside the repo root via npm link). | ||
| const fileLinkTargets = new Set(edges.filter(e => e.to?.isLink && e.to.resolved?.startsWith('file:')).map(e => e.to.target)) | ||
| const isLocal = (n) => n.isWorkspace || node.fsChildren?.has(n) || fileLinkTargets.has(n) | ||
| const optionalDeps = edges.filter(edge => edge.optional).map(edge => edge.to.target) | ||
@@ -234,0 +236,0 @@ |
| // mix-in implementing the loadActual method | ||
| const { dirname, join, normalize, relative, resolve } = require('node:path') | ||
| const { dirname, join, normalize, relative, resolve, sep } = require('node:path') | ||
@@ -17,2 +17,3 @@ const PackageJson = require('@npmcli/package-json') | ||
| const PackageExtensions = require('../package-extensions.js') | ||
| const NpmExtension = require('../npm-extension.js') | ||
@@ -75,2 +76,3 @@ // public symbols | ||
| calcDepFlags(tree, !options.root) | ||
| this.#repropagateOverrides() | ||
| this.actualTree = treeCheck(tree) | ||
@@ -147,2 +149,3 @@ return this.actualTree | ||
| await this[_setWorkspaces](this.#actualTree) | ||
| this.#linkActualWorkspaces() | ||
@@ -180,2 +183,6 @@ this.#transplant(root) | ||
| this.#linkActualWorkspaces() | ||
| // .npm-extension runs before packageExtensions, matching the ideal-tree resolution order | ||
| await this.#applyNpmExtension() | ||
| this.#applyPackageExtensions() | ||
@@ -221,2 +228,44 @@ | ||
| // Under the linked (isolated) strategy, workspaces the root does not depend on are not symlinked into the root node_modules, so neither the on-disk scan nor the hidden lockfile gives the root's workspace edges a node_modules/<ws> link to resolve to. | ||
| // Synthesize those links from the authoritative workspaces map so npm ls and npm query surface the workspaces, matching hoisted and the logical package-lock. | ||
| #linkActualWorkspaces () { | ||
| const root = this.#actualTree | ||
| if (this.options.installStrategy !== 'linked' || !root.workspaces) { | ||
| return | ||
| } | ||
| // Declared workspaces ARE symlinked at root node_modules under linked, so a missing link there is a real problem that must surface as UNMET. | ||
| // Only undeclared workspaces are intentionally absent from root node_modules and need a synthesized link so their root edges resolve. | ||
| const pkg = root.package | ||
| const declared = new Set(Object.keys(Object.assign({}, | ||
| pkg.dependencies, | ||
| pkg.devDependencies, | ||
| pkg.optionalDependencies, | ||
| pkg.peerDependencies | ||
| ))) | ||
| // index loaded workspace targets by both path and realpath, so a workspace reached through a symlinked dir still matches | ||
| const byLoc = new Map() | ||
| for (const node of root.fsChildren) { | ||
| byLoc.set(node.path, node) | ||
| byLoc.set(node.realpath, node) | ||
| } | ||
| for (const [name, path] of root.workspaces.entries()) { | ||
| // declared workspaces, and any name already a root child, resolve their edge without a synthesized link | ||
| if (declared.has(name) || root.children.has(name)) { | ||
| continue | ||
| } | ||
| const target = byLoc.get(path) | ||
| if (target) { | ||
| // eslint-disable-next-line no-new | ||
| new Link({ | ||
| name, | ||
| path: resolve(root.path, 'node_modules', name), | ||
| realpath: target.realpath, | ||
| target, | ||
| parent: root, | ||
| root, | ||
| }) | ||
| } | ||
| } | ||
| } | ||
| #transplant (root) { | ||
@@ -274,2 +323,5 @@ if (!root || root === this.#actualTree) { | ||
| loadOverrides, | ||
| // A package physically located in the linked strategy's store is a transitive dependency, not a real tree top, so it must not load its devDependencies. | ||
| // Never flag the loaded root itself, even if its own path happens to sit under a .store directory. | ||
| isInStore: path !== this.path && real.includes(`${sep}node_modules${sep}.store${sep}`), | ||
| } | ||
@@ -392,2 +444,47 @@ | ||
| // Re-forward overrides through links once all edges are resolved, since a Link may forward before its subtree resolves and miss a transitive match (npm/cli#9619, #9659). | ||
| #repropagateOverrides () { | ||
| if (!this.#actualTree.overrides) { | ||
| return | ||
| } | ||
| for (const node of this.#actualTree.inventory.values()) { | ||
| if (node.isLink && node.overrides) { | ||
| node.recalculateOutEdgesOverrides() | ||
| } | ||
| } | ||
| } | ||
| // .npm-extension transformManifest, like packageExtensions, never rewrites a package's package.json, so re-derive its edges and provenance on a filesystem-scanned actual tree. | ||
| // This executes the root extension code; ignore-extension (and ignore-scripts via flatten) disables it. | ||
| async #applyNpmExtension () { | ||
| if (this.options.ignoreExtension) { | ||
| return | ||
| } | ||
| const ext = new NpmExtension({ | ||
| root: this.#actualTree.realpath, | ||
| extensionFile: this.options.extensionFile, | ||
| }) | ||
| if (!ext.present) { | ||
| return | ||
| } | ||
| await ext.load() | ||
| for (const node of this.#actualTree.inventory.values()) { | ||
| // only installed dependencies are transformed, never the root or a workspace | ||
| if (node.isLink || node.isProjectRoot || !node.name || !node.inNodeModules()) { | ||
| continue | ||
| } | ||
| const res = ext.apply(node.package) | ||
| if (res) { | ||
| node.package = res.pkg | ||
| node.npmExtensionApplied = res.applied | ||
| } | ||
| } | ||
| // mirror the provenance onto links so the logical tree location reports it too | ||
| for (const node of this.#actualTree.inventory.values()) { | ||
| if (node.isLink && node.target?.npmExtensionApplied) { | ||
| node.npmExtensionApplied = node.target.npmExtensionApplied | ||
| } | ||
| } | ||
| } | ||
| async #findMissingEdges () { | ||
@@ -409,5 +506,6 @@ // try to resolve any missing edges by walking up the directory tree, | ||
| for (const [name, edge] of node.edgesOut.entries()) { | ||
| const notMissing = !edge.missing && | ||
| !(edge.to && (edge.to.dummy || edge.to.parent !== node)) | ||
| if (notMissing) { | ||
| // An unresolved optional edge reports missing === false, so check the target directly. | ||
| // Otherwise an installed optional dep that lives only as a store sibling is never loaded. | ||
| const resolved = edge.to && !edge.to.dummy && edge.to.parent === node | ||
| if (resolved) { | ||
| continue | ||
@@ -414,0 +512,0 @@ } |
@@ -247,2 +247,3 @@ const { resolve } = require('node:path') | ||
| packageExtensionsApplied: sw.packageExtensionsApplied, | ||
| npmExtensionApplied: sw.npmExtensionApplied, | ||
| resolved: consistentResolve(sw.resolved, this.path, path), | ||
@@ -249,0 +250,0 @@ pkg: sw, |
+11
-5
@@ -155,7 +155,2 @@ // an object representing the set of vulnerabilities in a tree | ||
| // we will have loaded the source already if this is a metavuln | ||
| if (advisory.type === 'metavuln') { | ||
| vuln.addVia(this.get(advisory.dependency)) | ||
| } | ||
| // already marked this one, no need to do it again | ||
@@ -223,2 +218,13 @@ if (vuln.nodes.has(node)) { | ||
| // post-loop reconciliation: establish all via links | ||
| // done here to ensure all vulns exist, avoid redundant setter triggers, | ||
| // and correctly handle multiple paths and omission cleanups. | ||
| for (const vuln of this.values()) { | ||
| for (const advisory of vuln.advisories) { | ||
| if (advisory.type === 'metavuln') { | ||
| vuln.addVia(this.get(advisory.dependency)) | ||
| } | ||
| } | ||
| } | ||
| timeEnd() | ||
@@ -225,0 +231,0 @@ } |
@@ -38,8 +38,28 @@ // Dep flag (dev, peer, etc.) calculation requires default or reset flags. | ||
| } | ||
| node.target.dev = node.dev | ||
| node.target.optional = node.optional | ||
| node.target.devOptional = node.devOptional | ||
| node.target.peer = node.peer | ||
| node.target.extraneous = node.extraneous | ||
| queue.push(node.target) | ||
| // Only unset flags, matching the edge walk below, so multiple links to one target can't clobber a correct value. | ||
| let changed = false | ||
| if (node.target.dev && !node.dev) { | ||
| node.target.dev = false | ||
| changed = true | ||
| } | ||
| if (node.target.optional && !node.optional) { | ||
| node.target.optional = false | ||
| changed = true | ||
| } | ||
| if (node.target.devOptional && !node.devOptional) { | ||
| node.target.devOptional = false | ||
| changed = true | ||
| } | ||
| if (node.target.peer && !node.peer) { | ||
| node.target.peer = false | ||
| changed = true | ||
| } | ||
| if (node.target.extraneous && !node.extraneous) { | ||
| node.target.extraneous = false | ||
| changed = true | ||
| } | ||
| // queue target on first visit so its deps are walked | ||
| if (changed || !seen.has(node.target)) { | ||
| queue.push(node.target) | ||
| } | ||
| continue | ||
@@ -46,0 +66,0 @@ } |
+11
-1
@@ -163,3 +163,3 @@ // An edge in the dependency graph | ||
| explanation.from = this.#from.explain(null, seen) | ||
| // note when this edge was created by a root packageExtensions repair on the from node | ||
| // note when this edge was created or changed by a root packageExtensions repair on the from node | ||
| const applied = this.#from.packageExtensionsApplied | ||
@@ -174,2 +174,12 @@ if (applied) { | ||
| } | ||
| // note when this edge was created or changed by a root .npm-extension transformManifest repair on the from node | ||
| const transformed = this.#from.npmExtensionApplied | ||
| if (transformed) { | ||
| for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies', 'peerDependenciesMeta']) { | ||
| if (transformed[field]?.includes(this.#name)) { | ||
| explanation.npmExtension = { extensionPoint: transformed.extensionPoint, field } | ||
| break | ||
| } | ||
| } | ||
| } | ||
| } | ||
@@ -176,0 +186,0 @@ this.#explanation = explanation |
+1
-0
@@ -8,1 +8,2 @@ module.exports = require('./arborist/index.js') | ||
| module.exports.PackageExtensions = require('./package-extensions.js') | ||
| module.exports.NpmExtension = require('./npm-extension.js') |
@@ -5,9 +5,2 @@ // Alternate versions of different classes that we use for isolated mode | ||
| // fake lib/inventory.js | ||
| class IsolatedInventory extends Map { | ||
| query () { | ||
| return [] | ||
| } | ||
| } | ||
| // fake lib/node.js | ||
@@ -21,3 +14,3 @@ class IsolatedNode { | ||
| integrity = null | ||
| inventory = new IsolatedInventory() | ||
| inventory = new Map() | ||
| isInStore = false | ||
@@ -24,0 +17,0 @@ inBundle = false |
+27
-11
@@ -112,5 +112,3 @@ const relpath = require('./relpath.js') | ||
| // When a Link receives overrides (via edgesIn), forward them to the target node which holds the actual edgesOut — but only when the OverrideSet has at least one rule that names a dep the target actually depends on. | ||
| // Without this scope, the link forwards a generic ancestor OverrideSet that has no real effect on the target's edges, but still flips the target to "has overrides", which changes downstream `canReplaceWith` / placement decisions and causes `npm ci` to re-resolve lockfile-pinned edges from the registry. | ||
| // See npm/cli#9357. | ||
| // Forward overrides to the target only when a rule names a dep it needs, directly or transitively (npm/cli#9357, #9619). | ||
| recalculateOutEdgesOverrides () { | ||
@@ -120,10 +118,3 @@ if (!this.target || !this.overrides) { | ||
| } | ||
| let hasMatchingRule = false | ||
| for (const rule of this.overrides.ruleset.values()) { | ||
| if (this.target.edgesOut.has(rule.name)) { | ||
| hasMatchingRule = true | ||
| break | ||
| } | ||
| } | ||
| if (!hasMatchingRule) { | ||
| if (!overrideMatchesSubtree(this.overrides, this.target)) { | ||
| return | ||
@@ -148,2 +139,27 @@ } | ||
| // True when an override rule applies to an edge reachable from the target at any depth. | ||
| // getEdgeRule matches on name and spec, returning the set itself when nothing applies, so a non-applicable version-qualified rule doesn't flip an intermediate node to "has overrides" (npm/cli#9357). | ||
| // Not a method: runs from the Node super-constructor, before subclass private methods exist. | ||
| const overrideMatchesSubtree = (overrides, target) => { | ||
| const seen = new Set() | ||
| const stack = [target] | ||
| while (stack.length) { | ||
| const node = stack.pop() | ||
| const resolved = node.isLink ? node.target : node | ||
| if (seen.has(resolved)) { | ||
| continue | ||
| } | ||
| seen.add(resolved) | ||
| for (const edge of resolved.edgesOut.values()) { | ||
| if (overrides.getEdgeRule(edge).name === edge.name) { | ||
| return true | ||
| } | ||
| if (edge.to) { | ||
| stack.push(edge.to) | ||
| } | ||
| } | ||
| } | ||
| return false | ||
| } | ||
| module.exports = Link |
+6
-1
@@ -97,2 +97,3 @@ // inventory, path, realpath, root, and parent | ||
| packageExtensionsApplied = null, | ||
| npmExtensionApplied = null, | ||
| parent, | ||
@@ -180,2 +181,5 @@ patched = null, | ||
| this.packageExtensionsApplied = packageExtensionsApplied | ||
| // Provenance for a root .npm-extension transformManifest repair applied to this node's manifest, or null. | ||
| // Shape: { extensionPoint, dependencies?, optionalDependencies?, peerDependencies?, peerDependenciesMeta? }. | ||
| this.npmExtensionApplied = npmExtensionApplied | ||
| this.installLinks = installLinks | ||
@@ -937,3 +941,4 @@ this.legacyPeerDeps = legacyPeerDeps | ||
| } = sourceReference || {} | ||
| const thisDev = isTop && !globalTop && path | ||
| // A package in the linked strategy's .store is a transitive dependency that is structurally a tree top, but its devDependencies are never installed or required, so they must not be loaded. | ||
| const thisDev = isTop && !globalTop && path && !this.isInStore | ||
| const srcDev = !sourceReference || srcTop && !srcGlobalTop && srcPath | ||
@@ -940,0 +945,0 @@ if (thisDev && srcDev) { |
+3
-0
@@ -253,2 +253,5 @@ // Given a dep, a node that depends on it, and the edge representing that | ||
| : {}), | ||
| ...(this.dep.npmExtensionApplied | ||
| ? { npmExtensionApplied: this.dep.npmExtensionApplied } | ||
| : {}), | ||
| ...(this.dep.overrides ? { overrides: this.dep.overrides } : {}), | ||
@@ -255,0 +258,0 @@ ...(this.dep.isLink ? { target: this.dep.target, realpath: this.dep.realpath } : {}), |
@@ -801,3 +801,4 @@ 'use strict' | ||
| for (const link of node.linksIn) { | ||
| if (link.parent === compareNode) { | ||
| // A store-backing node (linked strategy) is reached through its logical Link, which matches via edgesIn below, so the store node itself is never a direct child. | ||
| if (link.parent === compareNode && !node.isInStore) { | ||
| return true | ||
@@ -804,0 +805,0 @@ } |
@@ -46,3 +46,5 @@ const npa = require('npm-package-arg') | ||
| for (const [key, value] of Object.entries(policy)) { | ||
| if (!matches(node, key)) { | ||
| // Pass deny intent so matchRegistry can fail closed on an unverifiable | ||
| // version: a deny still blocks, an allow stays refused. | ||
| if (!matches(node, key, value === false)) { | ||
| continue | ||
@@ -70,3 +72,3 @@ } | ||
| const matches = (node, key) => { | ||
| const matches = (node, key, failClosed) => { | ||
| let parsed | ||
@@ -83,3 +85,3 @@ try { | ||
| case 'version': | ||
| return matchRegistry(node, parsed) | ||
| return matchRegistry(node, parsed, failClosed) | ||
| case 'git': | ||
@@ -138,3 +140,3 @@ return matchGit(node, parsed) | ||
| const matchRegistry = (node, parsed) => { | ||
| const matchRegistry = (node, parsed, failClosed) => { | ||
| // If this node is not a registry dep, refuse the match. A registry-style | ||
@@ -175,5 +177,10 @@ // key (`pkg`, `pkg@1`, `pkg@1 || 2`) must not match a tarball or git node | ||
| } | ||
| if (!trusted.version || !isExactVersionDisjunction(parsed.fetchSpec)) { | ||
| if (!isExactVersionDisjunction(parsed.fetchSpec)) { | ||
| return false | ||
| } | ||
| // Unverifiable version (omit-lockfile-registry-resolved): a deny blocks, | ||
| // an allow is refused. | ||
| if (!trusted.version) { | ||
| return failClosed | ||
| } | ||
| return semver.satisfies(trusted.version, parsed.fetchSpec, { loose: true }) | ||
@@ -186,2 +193,6 @@ } | ||
| if (parsed.type === 'version') { | ||
| // Unverifiable version: a deny blocks, an allow is refused. | ||
| if (!trusted.version) { | ||
| return failClosed | ||
| } | ||
| return trusted.version === parsed.fetchSpec | ||
@@ -375,2 +386,3 @@ } | ||
| module.exports.isScriptAllowed = isScriptAllowed | ||
| module.exports.matches = matches | ||
| module.exports.isExactVersionDisjunction = isExactVersionDisjunction | ||
@@ -377,0 +389,0 @@ module.exports.getTrustedRegistryIdentity = getTrustedRegistryIdentity |
+41
-4
@@ -118,2 +118,3 @@ // a module that manages a lockfile (package-lock.json). | ||
| 'packageExtensionsApplied', | ||
| 'npmExtensionApplied', | ||
| ] | ||
@@ -181,2 +182,31 @@ | ||
| // The walk above can't reach two linked-strategy layouts: a store package's sibling deps under .store/<key>/node_modules (.store is a skipped dot-dir), and an undeclared workspace not symlinked into root node_modules. Walk those dirs, derived from the lockfile entries. | ||
| // A dir reachable through a link entry is skipped: it was already reached (or, if the symlink is stale, correctly stays unseen so the lockfile is rejected). This keeps the hoisted strategy's stale-symlink detection intact, since there every workspace is a link target. | ||
| const linkTargets = new Set() | ||
| for (const loc in data.packages) { | ||
| const { link, resolved } = data.packages[loc] | ||
| if (link && resolved) { | ||
| linkTargets.add(resolved.replace(/\\/g, '/')) | ||
| } | ||
| } | ||
| const extraDirs = new Set() | ||
| for (const loc in data.packages) { | ||
| const store = loc.match(/^(.*\/\.store\/.+?)\/node_modules\//) | ||
| if (store) { | ||
| // .store/<key> is never walked but has no entry, so mark it seen. | ||
| seen.add(store[1]) | ||
| extraDirs.add(`${store[1]}/node_modules`) | ||
| continue | ||
| } | ||
| // A workspace/fsChild dir outside node_modules, e.g. packages/a. | ||
| const i = loc.indexOf('/node_modules/') | ||
| const root = i === -1 ? loc : loc.slice(0, i) | ||
| if (root && !/(^|\/)node_modules(\/|$)/.test(root) && !linkTargets.has(root)) { | ||
| extraDirs.add(root) | ||
| } | ||
| } | ||
| for (const rel of extraDirs) { | ||
| await assertNoNewer(path, data, lockTime, resolve(path, rel), seen) | ||
| } | ||
| // assert that all the entries in the lockfile were seen | ||
@@ -361,2 +391,3 @@ for (const loc in data.packages) { | ||
| this.packageExtensionsHash = null | ||
| this.npmExtensionHash = null | ||
| const lockfileVersion = this.lockfileVersion || defaultLockfileVersion | ||
@@ -502,2 +533,4 @@ this.originalLockfileVersion = lockfileVersion | ||
| this.packageExtensionsHash = data.packages?.['']?.packageExtensionsHash || null | ||
| // the .npm-extension file hash, if the lockfile recorded one on its root entry | ||
| this.npmExtensionHash = data.packages?.['']?.npmExtensionHash || null | ||
@@ -925,2 +958,6 @@ // use default if it wasn't explicitly set, and the current file is | ||
| } | ||
| // record the .npm-extension file hash on the root entry for the same reason | ||
| if (this.npmExtensionHash) { | ||
| root.npmExtensionHash = this.npmExtensionHash | ||
| } | ||
| this.data.packages = {} | ||
@@ -979,8 +1016,8 @@ if (Object.keys(root).length) { | ||
| } | ||
| // packageExtensions state likewise forces lockfileVersion 4 so older clients abort instead of dropping the repaired graph | ||
| // packageExtensions and .npm-extension state likewise force lockfileVersion 4 so older clients abort instead of dropping the repaired graph | ||
| const hasExtensionState = !this.hiddenLockfile && | ||
| (this.packageExtensionsHash || | ||
| Object.values(this.data.packages).some(p => p.packageExtensionsApplied)) | ||
| (this.packageExtensionsHash || this.npmExtensionHash || | ||
| Object.values(this.data.packages).some(p => p.packageExtensionsApplied || p.npmExtensionApplied)) | ||
| if (hasExtensionState && this.lockfileVersion < packageExtensionsLockfileVersion) { | ||
| log.warn('shrinkwrap', `packageExtensions requires lockfileVersion ${packageExtensionsLockfileVersion}; upgrading the lockfile from version ${this.lockfileVersion}.`) | ||
| log.warn('shrinkwrap', `manifest extensions require lockfileVersion ${packageExtensionsLockfileVersion}; upgrading the lockfile from version ${this.lockfileVersion}.`) | ||
| this.lockfileVersion = packageExtensionsLockfileVersion | ||
@@ -987,0 +1024,0 @@ } |
@@ -53,2 +53,9 @@ const isScriptAllowed = require('./script-allowed.js') | ||
| } | ||
| if (node.inert) { | ||
| // Inert = an optional dep that can't be installed here (failed the | ||
| // os/cpu/libc or engine check, or failed to load). reify drops it | ||
| // before any script runs, so its install scripts never execute and it | ||
| // must not be flagged (npm/cli#9562). | ||
| continue | ||
| } | ||
@@ -55,0 +62,0 @@ const verdict = isScriptAllowed(node, resolvedPolicy) |
+1
-1
| { | ||
| "name": "@npmcli/arborist", | ||
| "version": "10.0.0-pre.1", | ||
| "version": "10.0.0-pre.2", | ||
| "description": "Manage node_modules trees", | ||
@@ -5,0 +5,0 @@ "dependencies": { |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Dynamic require
Supply chain riskDynamic require can indicate the package is performing dangerous or unsafe dynamic code execution.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
598423
5.84%70
1.45%15143
4.67%25
8.7%