252 lines
9.5 KiB
JavaScript
252 lines
9.5 KiB
JavaScript
|
|
import { execFileSync } from 'node:child_process';
|
||
|
|
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||
|
|
import { basename, dirname, join, resolve } from 'node:path';
|
||
|
|
|
||
|
|
const [planPath, lockPath, nodeModulesPath, outputPath] = process.argv.slice(2);
|
||
|
|
if ([planPath, lockPath, nodeModulesPath, outputPath].some((value) => value === undefined)) {
|
||
|
|
throw new Error('usage: generate-third-party-notices.mjs PLAN LOCK NODE_MODULES OUTPUT');
|
||
|
|
}
|
||
|
|
|
||
|
|
const plan = readJson(planPath);
|
||
|
|
const lock = readJson(lockPath);
|
||
|
|
const cabalCache = fieldFromCommand('cabal', ['path'], 'cache-home');
|
||
|
|
const ghcLibDir = run('ghc', ['--print-libdir']).trim();
|
||
|
|
const ghcRoot = resolve(ghcLibDir, '../../..');
|
||
|
|
const ghcDocParent = join(ghcRoot, 'share', 'doc');
|
||
|
|
const ghcDocRootName = readdirSync(ghcDocParent).find((name) => name.endsWith(`-ghc-${compilerVersion(plan)}`));
|
||
|
|
if (ghcDocRootName === undefined) throw new Error('GHC package license directory not found');
|
||
|
|
const ghcDocRoot = join(ghcDocParent, ghcDocRootName);
|
||
|
|
|
||
|
|
const haskellPackages = runtimePackages(plan['install-plan'])
|
||
|
|
.map((pkg) => haskellNotice(pkg, cabalCache, ghcDocRoot, compilerVersion(plan)))
|
||
|
|
.sort(comparePackage);
|
||
|
|
const npmPackages = npmProductionPackages(lock)
|
||
|
|
.map((pkg) => npmNotice(pkg, nodeModulesPath))
|
||
|
|
.sort(comparePackage);
|
||
|
|
validateLicenses([...haskellPackages, ...npmPackages]);
|
||
|
|
|
||
|
|
const sections = [
|
||
|
|
'NUTHATCH THIRD-PARTY NOTICES',
|
||
|
|
'',
|
||
|
|
'This file is generated by scripts/generate-third-party-notices.mjs.',
|
||
|
|
'It conservatively covers the Haskell dependency plan selected for exe:nuthatch',
|
||
|
|
'and every non-development package in the locked browser dependency closure.',
|
||
|
|
'',
|
||
|
|
'The runtime image separately records its installed Debian packages in',
|
||
|
|
'/usr/share/doc/nuthatch/debian-packages.txt. Their copyright and license',
|
||
|
|
'materials remain in the image under /usr/share/doc.',
|
||
|
|
'',
|
||
|
|
'Container images used only by the test fixture are separate programs and are',
|
||
|
|
'not redistributed as part of Nuthatch. Their licenses remain their own.',
|
||
|
|
'',
|
||
|
|
inventory('HASKELL INVENTORY', haskellPackages),
|
||
|
|
inventory('BROWSER PRODUCTION DEPENDENCY INVENTORY', npmPackages),
|
||
|
|
'LICENSE AND NOTICE TEXTS',
|
||
|
|
...haskellPackages.flatMap(renderNotice),
|
||
|
|
...npmPackages.flatMap(renderNotice)
|
||
|
|
];
|
||
|
|
|
||
|
|
writeFileSync(outputPath, `${sections.join('\n').replace(/\n+$/, '')}\n`);
|
||
|
|
|
||
|
|
function readJson(path) {
|
||
|
|
return JSON.parse(readFileSync(path, 'utf8'));
|
||
|
|
}
|
||
|
|
|
||
|
|
function run(command, args) {
|
||
|
|
return execFileSync(command, args, { encoding: 'utf8' });
|
||
|
|
}
|
||
|
|
|
||
|
|
function fieldFromCommand(command, args, field) {
|
||
|
|
const prefix = `${field}:`;
|
||
|
|
const line = run(command, args).split('\n').find((candidate) => candidate.startsWith(prefix));
|
||
|
|
if (line === undefined) throw new Error(`${field} not reported by ${command}`);
|
||
|
|
return line.slice(prefix.length).trim();
|
||
|
|
}
|
||
|
|
|
||
|
|
function compilerVersion(planValue) {
|
||
|
|
const match = /^ghc-(.+)$/.exec(planValue['compiler-id']);
|
||
|
|
if (match === null) throw new Error(`unsupported compiler id ${planValue['compiler-id']}`);
|
||
|
|
return match[1];
|
||
|
|
}
|
||
|
|
|
||
|
|
function uniquePackages(packages) {
|
||
|
|
const unique = new Map();
|
||
|
|
for (const pkg of packages) {
|
||
|
|
const key = `${pkg['pkg-name']}\u0000${pkg['pkg-version']}`;
|
||
|
|
const existing = unique.get(key);
|
||
|
|
if (existing !== undefined && existing.type !== pkg.type) {
|
||
|
|
throw new Error(`inconsistent package source for ${pkg['pkg-name']}-${pkg['pkg-version']}`);
|
||
|
|
}
|
||
|
|
unique.set(key, pkg);
|
||
|
|
}
|
||
|
|
return [...unique.values()];
|
||
|
|
}
|
||
|
|
|
||
|
|
function runtimePackages(packages) {
|
||
|
|
const byId = new Map(packages.map((pkg) => [pkg.id, pkg]));
|
||
|
|
const root = packages.find((pkg) => pkg['pkg-name'] === 'nuthatch' && pkg['component-name'] === 'exe:nuthatch');
|
||
|
|
if (root === undefined) throw new Error('exe:nuthatch not found in Cabal plan');
|
||
|
|
const selected = [];
|
||
|
|
const visited = new Set();
|
||
|
|
const pending = [...(root.depends ?? [])];
|
||
|
|
while (pending.length > 0) {
|
||
|
|
const id = pending.pop();
|
||
|
|
if (visited.has(id)) continue;
|
||
|
|
visited.add(id);
|
||
|
|
const pkg = byId.get(id);
|
||
|
|
if (pkg === undefined) throw new Error(`Cabal dependency ${id} not found in plan`);
|
||
|
|
pending.push(...(pkg.depends ?? []));
|
||
|
|
if (pkg['pkg-name'] !== 'nuthatch') selected.push(pkg);
|
||
|
|
}
|
||
|
|
return uniquePackages(selected);
|
||
|
|
}
|
||
|
|
|
||
|
|
function haskellNotice(pkg, cacheRoot, docRoot, ghcVersion) {
|
||
|
|
const name = pkg['pkg-name'];
|
||
|
|
const version = pkg['pkg-version'];
|
||
|
|
if (pkg.type === 'configured') {
|
||
|
|
const archive = join(cacheRoot, 'packages', 'hackage.haskell.org', name, version, `${name}-${version}.tar.gz`);
|
||
|
|
if (!existsSync(archive)) {
|
||
|
|
run('cabal', ['get', `${name}-${version}`, '--destdir=dist-newstyle/third-party/license-sources']);
|
||
|
|
}
|
||
|
|
if (!existsSync(archive)) throw new Error(`Hackage archive not found after fetch: ${archive}`);
|
||
|
|
const entries = run('tar', ['-tzf', archive]).trim().split('\n');
|
||
|
|
const cabalEntry = entries.find((entry) => dirname(entry) === `${name}-${version}` && entry.endsWith('.cabal'));
|
||
|
|
if (cabalEntry === undefined) throw new Error(`Cabal metadata not found in ${archive}`);
|
||
|
|
const cabalText = run('tar', ['-xOzf', archive, cabalEntry]);
|
||
|
|
const licenseValue = /^license:\s*(\S.*?)\s*$/im.exec(cabalText)?.[1];
|
||
|
|
if (licenseValue === undefined) throw new Error(`license field not found for ${name}-${version}`);
|
||
|
|
const license = normalizeLicense(licenseValue);
|
||
|
|
const documents = archiveDocuments(archive, entries, `${name}-${version}`);
|
||
|
|
if (documents.length === 0) {
|
||
|
|
documents.push({
|
||
|
|
name: 'PACKAGE METADATA',
|
||
|
|
text: `The source archive supplies no standalone license or notice file.\nIts Cabal metadata declares ${license}.\n`
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
ecosystem: 'Haskell',
|
||
|
|
name,
|
||
|
|
version,
|
||
|
|
license,
|
||
|
|
documents
|
||
|
|
};
|
||
|
|
}
|
||
|
|
if (pkg.type === 'pre-existing') {
|
||
|
|
const packageId = `${name}-${version}`;
|
||
|
|
const licenseOutput = run('ghc-pkg', ['--global', 'field', packageId, 'license']);
|
||
|
|
const licenseValue = /^license:\s*(\S.*?)\s*$/im.exec(licenseOutput)?.[1];
|
||
|
|
if (licenseValue === undefined) throw new Error(`GHC package license not found for ${packageId}`);
|
||
|
|
const license = normalizeLicense(licenseValue);
|
||
|
|
const packageLicense = join(docRoot, packageId, 'LICENSE');
|
||
|
|
const fallbackLicense = join(docRoot, `ghc-${ghcVersion}`, 'LICENSE');
|
||
|
|
const licensePath = existsSync(packageLicense) ? packageLicense : fallbackLicense;
|
||
|
|
if (!existsSync(licensePath)) throw new Error(`license text not found for ${packageId}`);
|
||
|
|
return {
|
||
|
|
ecosystem: 'Haskell',
|
||
|
|
name,
|
||
|
|
version,
|
||
|
|
license,
|
||
|
|
documents: [{ name: 'LICENSE', text: readFileSync(licensePath, 'utf8') }]
|
||
|
|
};
|
||
|
|
}
|
||
|
|
throw new Error(`unsupported Cabal plan package type ${pkg.type}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
function archiveDocuments(archive, entries, root) {
|
||
|
|
const candidates = entries.filter((entry) => {
|
||
|
|
const relative = entry.slice(root.length + 1);
|
||
|
|
return !relative.includes('/') && /^(licen[cs]e|copying|notice)([._-].*)?$/i.test(relative);
|
||
|
|
});
|
||
|
|
return candidates.sort().map((entry) => ({
|
||
|
|
name: basename(entry),
|
||
|
|
text: run('tar', ['-xOzf', archive, entry])
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
function npmProductionPackages(lockValue) {
|
||
|
|
return Object.entries(lockValue.packages)
|
||
|
|
.filter(([path, metadata]) => path !== '' && metadata.dev !== true)
|
||
|
|
.map(([path, metadata]) => ({
|
||
|
|
path: path.replace(/^node_modules\//, ''),
|
||
|
|
name: metadata.name ?? path.replace(/^node_modules\//, ''),
|
||
|
|
version: metadata.version,
|
||
|
|
license: metadata.license
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
function npmNotice(pkg, modulesRoot) {
|
||
|
|
if (pkg.version === undefined || pkg.license === undefined) {
|
||
|
|
throw new Error(`unclassified npm package ${pkg.name}`);
|
||
|
|
}
|
||
|
|
const packageRoot = join(modulesRoot, pkg.path);
|
||
|
|
const candidates = readdirSync(packageRoot)
|
||
|
|
.filter((name) => /^(licen[cs]e|copying|notice)([._-].*)?$/i.test(name))
|
||
|
|
.sort();
|
||
|
|
const documents = candidates.map((name) => ({ name, text: readFileSync(join(packageRoot, name), 'utf8') }));
|
||
|
|
if (documents.length === 0) {
|
||
|
|
documents.push({
|
||
|
|
name: 'PACKAGE METADATA',
|
||
|
|
text: `The installed package supplies no standalone license or notice file.\nIts locked metadata declares ${pkg.license}.\n`
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
ecosystem: 'npm',
|
||
|
|
name: pkg.name,
|
||
|
|
version: pkg.version,
|
||
|
|
license: pkg.license,
|
||
|
|
documents
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function comparePackage(left, right) {
|
||
|
|
return compareText(left.name, right.name) || compareText(left.version, right.version);
|
||
|
|
}
|
||
|
|
|
||
|
|
function compareText(left, right) {
|
||
|
|
return left < right ? -1 : left > right ? 1 : 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeLicense(license) {
|
||
|
|
if (license === 'BSD2') return 'BSD-2-Clause';
|
||
|
|
if (license === 'BSD3') return 'BSD-3-Clause';
|
||
|
|
return license;
|
||
|
|
}
|
||
|
|
|
||
|
|
function validateLicenses(packages) {
|
||
|
|
const allowed = new Set(['Apache-2.0', 'BSD-2-Clause', 'BSD-3-Clause', 'ISC', 'MIT']);
|
||
|
|
const unsupported = packages.filter((pkg) => !allowed.has(pkg.license));
|
||
|
|
if (unsupported.length > 0) {
|
||
|
|
throw new Error(`unsupported shipped licenses: ${unsupported.map((pkg) => `${pkg.name} (${pkg.license})`).join(', ')}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function inventory(title, packages) {
|
||
|
|
return [
|
||
|
|
title,
|
||
|
|
...packages.map((pkg) => `${pkg.name}\t${pkg.version}\t${pkg.license}`),
|
||
|
|
''
|
||
|
|
].join('\n');
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderNotice(pkg) {
|
||
|
|
const separator = '='.repeat(79);
|
||
|
|
return [
|
||
|
|
separator,
|
||
|
|
`${pkg.ecosystem}: ${pkg.name} ${pkg.version} (${pkg.license})`,
|
||
|
|
...pkg.documents.flatMap((document) => [
|
||
|
|
`--- ${document.name} ---`,
|
||
|
|
normalizeDocument(document.text),
|
||
|
|
''
|
||
|
|
])
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeDocument(document) {
|
||
|
|
return document
|
||
|
|
.replace(/\r\n?/g, '\n')
|
||
|
|
.split('\n')
|
||
|
|
.map((line) => line.replace(/[ \t]+$/, ''))
|
||
|
|
.join('\n')
|
||
|
|
.replace(/\n+$/, '');
|
||
|
|
}
|