npm
Version:
a package manager for JavaScript
237 lines (215 loc) • 10.8 kB
JavaScript
// Root-owned `packageExtensions`: declarative repairs to third-party manifests applied before Arborist reads a candidate's dependency edges.
// See RFC: https://github.com/npm/rfcs/pull/889
// This module is pure: it parses and validates the root rule set, matches a candidate manifest by name and version, and returns an extended manifest copy plus minimal provenance.
// It never mutates the input manifest or any shared cache object.
const semver = require('semver')
const ssri = require('ssri')
const validateName = require('validate-npm-package-name')
// The only manifest fields a package extension may add or correct, because they are the fields that affect dependency and peer resolution.
const EXTENSION_FIELDS = [
'dependencies',
'optionalDependencies',
'peerDependencies',
'peerDependenciesMeta',
]
// The two normal dependency fields; a name may exist in only one of them.
const NORMAL_DEP_FIELDS = ['dependencies', 'optionalDependencies']
const err = (message, code, extra = {}) =>
Object.assign(new Error(message), { code, ...extra })
// Parse a selector key into { name, range }, where range is null for a name-only key.
// Selectors are a package name with an optional semver range; dist-tags, git, file, directory, url, and alias specs are rejected.
const parseSelector = key => {
if (typeof key !== 'string' || !key) {
throw err(`Invalid packageExtensions selector: ${JSON.stringify(key)}`, 'EEXTENSIONSELECTOR')
}
// The separator @ is the first @ after a leading scope @.
const at = key.indexOf('@', key.startsWith('@') ? 1 : 0)
const name = at === -1 ? key : key.slice(0, at)
const range = at === -1 ? null : key.slice(at + 1)
const { validForOldPackages, validForNewPackages } = validateName(name)
if (!validForOldPackages && !validForNewPackages) {
throw err(`Invalid package name in packageExtensions selector: "${key}"`, 'EEXTENSIONSELECTOR', { selector: key })
}
// A blank range such as "foo@" is malformed; the name-only form "foo" is how you match every version.
if (range !== null && range.trim() === '') {
throw err(
`Invalid packageExtensions selector: "${key}". Use the name only to match every version.`,
'EEXTENSIONSELECTOR', { selector: key })
}
// A versioned selector must be a valid semver range, which rejects dist-tags, git, file, url, and alias specs.
if (range !== null && semver.validRange(range, { loose: true }) === null) {
throw err(
`Invalid version range in packageExtensions selector: "${key}". Selectors accept a package name with an optional semver range only.`,
'EEXTENSIONSELECTOR', { selector: key })
}
return { name, range }
}
// A selector matches a candidate manifest by its own name and version.
// Name-only selectors match every version, including non-semver versions.
// Versioned selectors only match versions that parse as semver and satisfy the range.
const rangeMatches = (range, version) => {
if (range === null) {
return true
}
return semver.valid(version, { loose: true }) !== null &&
semver.satisfies(version, range, { loose: true })
}
// Validate a single selector's extension object before it is ever applied.
const validateExtensionObject = (key, ext) => {
if (ext === null || typeof ext !== 'object' || Array.isArray(ext)) {
throw err(`packageExtensions["${key}"] must be an object`, 'EEXTENSIONVALUE', { selector: key })
}
for (const field of Object.keys(ext)) {
if (!EXTENSION_FIELDS.includes(field)) {
throw err(
`packageExtensions["${key}"] has unsupported field "${field}". Supported fields: ${EXTENSION_FIELDS.join(', ')}.`,
'EEXTENSIONFIELD', { selector: key, field })
}
const val = ext[field]
if (val === null || typeof val !== 'object' || Array.isArray(val)) {
throw err(`packageExtensions["${key}"].${field} must be an object`, 'EEXTENSIONVALUE', { selector: key, field })
}
}
// Deletion is not supported in v1, so a null, false, or "-" value is an error.
for (const field of [...NORMAL_DEP_FIELDS, 'peerDependencies']) {
for (const [name, spec] of Object.entries(ext[field] || {})) {
if (spec === null || spec === false || spec === '-') {
throw err(
`packageExtensions["${key}"].${field}.${name} attempts deletion, which is not supported.`,
'EEXTENSIONDELETE', { selector: key, field, name })
}
}
}
// Each peerDependenciesMeta entry must be a non-null metadata object, never a deletion sentinel or primitive.
for (const [name, meta] of Object.entries(ext.peerDependenciesMeta || {})) {
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
throw err(
`packageExtensions["${key}"].peerDependenciesMeta.${name} must be an object`,
'EEXTENSIONVALUE', { selector: key, field: 'peerDependenciesMeta', name })
}
}
}
// Apply a matched extension to a manifest, returning { pkg, applied } where pkg is a copy with extended fields and applied is minimal provenance.
// The input manifest is never mutated.
const applyExtension = (pkg, { key, ext }) => {
const applied = { selector: key }
// Clone only the fields we may touch; the rest of the manifest is shared by reference since it is never mutated.
const next = { ...pkg }
for (const field of EXTENSION_FIELDS) {
if (pkg[field] && typeof pkg[field] === 'object') {
next[field] = field === 'peerDependenciesMeta'
? Object.fromEntries(Object.entries(pkg[field]).map(([n, m]) => [n, { ...m }]))
: { ...pkg[field] }
}
}
// dependencies and optionalDependencies add missing names only.
// A name already declared in either normal dependency field is an error, which also prevents moving a name between the fields.
for (const field of NORMAL_DEP_FIELDS) {
const adds = ext[field]
if (!adds) {
continue
}
for (const [name, spec] of Object.entries(adds)) {
for (const existingField of NORMAL_DEP_FIELDS) {
if (next[existingField] && name in next[existingField]) {
throw err(
`packageExtensions["${key}"].${field}.${name} conflicts with the package's existing ${existingField}.${name}. Use overrides to change a dependency version; packageExtensions only adds missing dependencies.`,
'EEXTENSIONDUPDEP', { selector: key, field, name, existingField })
}
}
next[field] = next[field] || {}
next[field][name] = spec
;(applied[field] = applied[field] || []).push(name)
}
}
// peerDependencies shallow-merges by peer name, and the extension value replaces an existing range.
if (ext.peerDependencies) {
next.peerDependencies = next.peerDependencies || {}
for (const [name, spec] of Object.entries(ext.peerDependencies)) {
next.peerDependencies[name] = spec
;(applied.peerDependencies = applied.peerDependencies || []).push(name)
}
}
// peerDependenciesMeta merges by peer name, then shallow-merges each meta object so an extension can add optional without dropping other meta keys.
if (ext.peerDependenciesMeta) {
next.peerDependenciesMeta = next.peerDependenciesMeta || {}
for (const [name, meta] of Object.entries(ext.peerDependenciesMeta)) {
next.peerDependenciesMeta[name] = { ...next.peerDependenciesMeta[name], ...meta }
;(applied.peerDependenciesMeta = applied.peerDependenciesMeta || []).push(name)
// Every peerDependenciesMeta entry an extension adds must correspond to a peerDependencies entry present after extension application.
if (!next.peerDependencies || !(name in next.peerDependencies)) {
throw err(
`packageExtensions["${key}"].peerDependenciesMeta.${name} has no corresponding peerDependencies.${name} after extension application.`,
'EEXTENSIONORPHANMETA', { selector: key, name })
}
}
}
return { pkg: next, applied }
}
// Deterministic JSON for hashing: keys sorted lexicographically at every level, string and number values preserved exactly, no insignificant whitespace.
const canonicalStringify = val => {
if (Array.isArray(val)) {
return `[${val.map(canonicalStringify).join(',')}]`
}
if (val && typeof val === 'object') {
return `{${Object.keys(val).sort()
.map(k => `${JSON.stringify(k)}:${canonicalStringify(val[k])}`)
.join(',')}}`
}
return JSON.stringify(val)
}
// Hash the canonical form of the root packageExtensions object using npm's existing lockfile digest encoding.
const canonicalHash = packageExtensions =>
ssri.fromData(canonicalStringify(packageExtensions), { algorithms: ['sha512'] }).toString()
class PackageExtensions {
constructor (raw) {
this.raw = raw
this.present = raw !== undefined
this.selectors = []
this.hash = null
if (!this.present) {
return
}
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
throw err('packageExtensions must be an object', 'EEXTENSIONROOT')
}
for (const [key, ext] of Object.entries(raw)) {
const { name, range } = parseSelector(key)
validateExtensionObject(key, ext)
this.selectors.push({ key, name, range, ext })
}
this.hash = canonicalHash(raw)
}
// Non-throwing check used for warnings: whether any selector matches the candidate.
wouldMatch (name, version) {
return this.selectors.some(s => s.name === name && rangeMatches(s.range, version))
}
// Return the single selector matching a candidate manifest, or null.
// Throws EEXTENSIONCONFLICT when more than one selector matches the same candidate.
match (name, version) {
const matches = this.selectors.filter(s => s.name === name && rangeMatches(s.range, version))
if (matches.length > 1) {
const keys = matches.map(s => `"${s.key}"`).join(', ')
throw err(
`Multiple packageExtensions selectors match ${name}@${version}: ${keys}. Narrow or remove one of the overlapping rules.`,
'EEXTENSIONCONFLICT', { name, version, selectors: matches.map(s => s.key) })
}
return matches[0] || null
}
// Apply the matching extension to a manifest copy, returning { pkg, applied } or null when no selector matches.
// Throws on selector conflict or invalid merge.
apply (pkg) {
if (!this.present || !this.selectors.length || !pkg || !pkg.name) {
return null
}
const sel = this.match(pkg.name, pkg.version)
return sel ? applyExtension(pkg, sel) : null
}
}
module.exports = PackageExtensions
module.exports.PackageExtensions = PackageExtensions
module.exports.parseSelector = parseSelector
module.exports.rangeMatches = rangeMatches
module.exports.canonicalHash = canonicalHash
module.exports.canonicalStringify = canonicalStringify
module.exports.EXTENSION_FIELDS = EXTENSION_FIELDS