blob: 530f9bf3d28faf8c979cd4cc1f75849d062a2259 [file]
#!/usr/bin/env node
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// Drift guard for the `allowScripts` map in the root package.json.
//
// npm >= 11.6 asks for an explicit approval before running a dependency's
// lifecycle scripts, keyed by exact `name@version`. The map only helps while
// it matches package-lock.json: this PR chain started because two keyed
// versions trailed the lockfile after routine bumps, and the optional
// Darwin-only fsevents was never listed at all. This check fails on any of:
//
// 1. a lockfile entry with `hasInstallScript` that the map does not key
// (optionality is not an exemption — fsevents is exactly the case);
// 2. a map key whose `name@version` no longer matches a lockfile entry
// that carries install scripts (the stale-version failure mode);
// 3. a map value that is not a boolean, which silently means neither
// "approve" nor "deny" to the gate.
//
// It does not judge the true/false decisions themselves — only that every
// script-carrying package has one, and that nothing in the map is dead.
import { readFileSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const repoRoot = path.resolve(
path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')),
'..',
);
const args = process.argv.slice(2);
function readOption(flag) {
const index = args.indexOf(flag);
return index === -1 ? null : args[index + 1];
}
const packageJsonPath = readOption('--package') ?? path.join(repoRoot, 'package.json');
const lockfilePath = readOption('--lock') ?? path.join(repoRoot, 'package-lock.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
const lock = JSON.parse(readFileSync(lockfilePath, 'utf8'));
const allowScripts = packageJson.allowScripts ?? null;
if (allowScripts === null || typeof allowScripts !== 'object' || Array.isArray(allowScripts)) {
console.error('package.json has no allowScripts object; nothing to guard.');
process.exit(1);
}
// A lockfile key is "node_modules/..." with the dependency name as the last
// `node_modules/` segment (scoped names span two path parts).
function nameFromLockKey(lockKey) {
const marker = lockKey.lastIndexOf('node_modules/');
return marker === -1 ? lockKey : lockKey.slice(marker + 'node_modules/'.length);
}
// Split an exact `name@version` key on its final '@' so scoped names survive.
function splitAllowKey(key) {
const at = key.lastIndexOf('@');
if (at <= 0) return null;
return { name: key.slice(0, at), version: key.slice(at + 1) };
}
const problems = [];
const lockEntries = new Map(); // name@version -> lockfile key
for (const [lockKey, entry] of Object.entries(lock.packages ?? {})) {
if (lockKey === '') continue; // the root project is not a dependency of itself
if (!entry.hasInstallScript) continue;
const exact = `${nameFromLockKey(lockKey)}@${entry.version}`;
if (lockEntries.has(exact)) {
problems.push(
`lockfile carries ${exact} at two paths: ${lockEntries.get(exact)} and ${lockKey}`,
);
continue;
}
lockEntries.set(exact, lockKey);
}
for (const [exact] of lockEntries) {
if (!(exact in allowScripts)) {
const entry = lock.packages[lockEntries.get(exact)] ?? {};
const where = entry.optional
? ` (optional${entry.os?.length ? `, os: ${entry.os.join('/')}` : ''})`
: '';
problems.push(`missing: ${exact}${where} has install scripts but no allowScripts decision`);
}
}
const keyedExact = new Set();
for (const [key, value] of Object.entries(allowScripts)) {
if (typeof value !== 'boolean') {
problems.push(
`invalid: "${key}" maps to ${JSON.stringify(value)}; the gate expects true or false`,
);
continue;
}
const split = splitAllowKey(key);
if (!split || !split.version) {
problems.push(`invalid: "${key}" is not an exact name@version key`);
continue;
}
keyedExact.add(key);
if (!lockEntries.has(key)) {
problems.push(
`stale: "${key}" matches no lockfile entry with install scripts (version bumped, or scripts gone)`,
);
}
}
if (problems.length > 0) {
console.error(`allowScripts is out of sync with ${path.basename(lockfilePath)}:\n`);
for (const problem of problems) console.error(` - ${problem}`);
console.error(
`\n${keyedExact.size} keyed / ${lockEntries.size} script-carrying packages. Fix package.json's allowScripts map.`,
);
process.exit(1);
}
console.log(
`allowScripts covers all ${lockEntries.size} install-script packages with exact name@version keys.`,
);