Add licensing and distribution metadata
This commit is contained in:
parent
30ae88f8a3
commit
5fb0c9c57b
1 changed files with 9231 additions and 63 deletions
252
scripts/generate-third-party-notices.mjs
Normal file
252
scripts/generate-third-party-notices.mjs
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
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+$/, '');
|
||||
}
|
||||
Loading…
Reference in a new issue