blob: 975fb6492b9a5fec59f5be8f9e9deaece507c443 [file]
/*
* 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.
*/
import { execFileSync, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
chmodSync,
closeSync,
createReadStream,
existsSync,
linkSync,
lstatSync,
mkdirSync,
mkdtempSync,
openSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, dirname, extname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const defaultRepoRoot = resolve(import.meta.dirname, '..');
const archivePattern = /^apache-maka-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)-incubating-src\.tar\.gz$/;
const versionPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
const requiredReleaseDocuments = ['DISCLAIMER-WIP', 'LICENSE', 'NOTICE'];
const requiredRootFiles = [...requiredReleaseDocuments, 'package.json', 'package-lock.json'];
const forbiddenSegments = new Set(['.agents', '.claude', '.git', '.maka-shots', 'node_modules']);
const forbiddenRootFiles = new Set(['maka-proposal-zh-review.txt']);
const knownNonCategoryXLicenses = new Set([
'(AFL-2.1 OR BSD-3-Clause)',
'(MIT OR CC0-1.0)',
'(MPL-2.0 OR Apache-2.0)',
'(WTFPL OR MIT)',
'0BSD',
'Apache-2.0',
'BSD',
'BSD-2-Clause',
'BSD-3-Clause',
'BlueOak-1.0.0',
'CC-BY-4.0',
'CC0-1.0',
'ISC',
'MIT',
'MIT OR Apache-2.0',
'MPL-2.0',
'OFL-1.1',
'Public Domain',
'Python-2.0',
'Unlicense',
'WTFPL',
'WTFPL OR ISC',
]);
const categoryXLicensePattern = /(?:^|[^A-Za-z])(?:A?GPL|LGPL)-?\d/i;
const packageDependencyFields = [
'dependencies',
'devDependencies',
'optionalDependencies',
'peerDependencies',
];
const bundledDependencyFields = ['bundleDependencies', 'bundledDependencies'];
const textSourceExtensions = new Set([
'.cjs',
'.css',
'.csv',
'.html',
'.js',
'.json',
'.jsonc',
'.jsonl',
'.lock',
'.md',
'.mjs',
'.mts',
'.nsh',
'.patch',
'.paths',
'.plist',
'.ps1',
'.py',
'.rs',
'.sh',
'.sql',
'.svg',
'.swift',
'.toml',
'.ts',
'.tsv',
'.tsx',
'.txt',
'.yaml',
'.yml',
]);
const textSourceBasenames = new Set([
'.git-blame-ignore-revs',
'.gitattributes',
'.gitignore',
'.mailmap',
'DISCLAIMER-WIP',
'Dockerfile',
'LICENSE',
'NOTICE',
'network-policy',
]);
const maxCommandBuffer = 64 * 1024 * 1024;
const rejectedGpgStatuses = new Set([
'BADSIG',
'ERRSIG',
'EXPKEYSIG',
'EXPSIG',
'FAILURE',
'KEYEXPIRED',
'KEYREVOKED',
'NODATA',
'NO_PUBKEY',
'REVKEYSIG',
]);
const allowedGpgHashAlgorithms = new Set([8, 9, 10]);
const rsaPublicKeyAlgorithms = new Set([1, 2, 3]);
const asciiArmoredSignaturePattern =
/^-----BEGIN PGP SIGNATURE-----\r?\n(?:[\x20-\x7e]*\r?\n)+-----END PGP SIGNATURE-----\r?\n?$/;
export function sourceCandidateIdentity(version) {
if (!versionPattern.test(version) || version.includes('incubating')) {
throw new Error(`Invalid release version: ${version}`);
}
const rootDirectory = `apache-maka-${version}-incubating`;
return {
archiveName: `${rootDirectory}-src.tar.gz`,
rootDirectory,
version,
};
}
export function parseSha512File(contents, expectedArchiveName) {
const match = /^([0-9a-fA-F]{128})[ \t]+\*?([^\r\n]+)\r?\n?$/.exec(contents);
if (!match) throw new Error('The SHA-512 file is not in sha512sum format');
if (match[2] !== expectedArchiveName) {
throw new Error(`The SHA-512 file names ${match[2]}, expected ${expectedArchiveName}`);
}
return match[1].toLowerCase();
}
export function validateArchiveEntries(entries, rootDirectory) {
const rootPrefix = `${rootDirectory}/`;
const seen = new Set();
const portableEntries = new Map();
for (const entry of entries) {
if (!entry) continue;
if (seen.has(entry)) throw new Error(`Duplicate archive entry: ${entry}`);
seen.add(entry);
const portableEntry = entry.normalize('NFC').toLowerCase();
const conflictingEntry = portableEntries.get(portableEntry);
if (conflictingEntry && conflictingEntry !== entry) {
throw new Error(`Cross-platform archive entry collision: ${conflictingEntry}, ${entry}`);
}
portableEntries.set(portableEntry, entry);
if (entry.startsWith('/') || entry.includes('\\')) {
throw new Error(`Unsafe archive entry: ${entry}`);
}
const segments = entry.split('/').filter(Boolean);
if (segments.includes('..') || segments.includes('.')) {
throw new Error(`Unsafe archive entry: ${entry}`);
}
if (entry !== rootDirectory && !entry.startsWith(rootPrefix)) {
throw new Error(`Archive entry is outside ${rootDirectory}: ${entry}`);
}
if (segments.some((segment) => forbiddenSegments.has(segment))) {
throw new Error(`Forbidden archive entry: ${entry}`);
}
if (segments.length === 2 && forbiddenRootFiles.has(segments[1])) {
throw new Error(`Forbidden archive entry: ${entry}`);
}
if (segments.at(-1) === '.DS_Store') {
throw new Error(`Forbidden archive entry: ${entry}`);
}
}
for (const requiredFile of requiredRootFiles) {
const requiredEntry = `${rootPrefix}${requiredFile}`;
if (!seen.has(requiredEntry)) {
throw new Error(`Required release document is missing: ${requiredFile}`);
}
}
}
export async function createSourceCandidate({
outputDirectory = join(defaultRepoRoot, 'release/asf'),
repositoryRoot = defaultRepoRoot,
revision = 'HEAD',
version,
}) {
const identity = sourceCandidateIdentity(version);
const commit = resolveCandidateCommit({ repositoryRoot, revision, version });
mkdirSync(outputDirectory, { recursive: true, mode: 0o755 });
const archivePath = resolve(outputDirectory, identity.archiveName);
const checksumPath = `${archivePath}.sha512`;
for (const outputPath of [archivePath, checksumPath, `${archivePath}.asc`]) {
if (existsSync(outputPath)) {
throw new Error(`Refusing to overwrite existing release output: ${outputPath}`);
}
}
const temporaryRoot = mkdtempSync(join(dirname(archivePath), '.maka-asf-source-'));
try {
const tarPath = join(temporaryRoot, 'source.tar');
const temporaryArchivePath = join(temporaryRoot, identity.archiveName);
const temporaryChecksumPath = `${temporaryArchivePath}.sha512`;
writeSourceTar({ commit, identity, repositoryRoot, tarPath, temporaryRoot });
execFileSync('gzip', ['-n', '-9', tarPath], {
env: controlledProcessEnvironment({ excludedNames: ['GZIP'] }),
stdio: 'inherit',
});
renameSync(`${tarPath}.gz`, temporaryArchivePath);
chmodSync(temporaryArchivePath, 0o644);
await writeSha512File(temporaryArchivePath, temporaryChecksumPath);
await verifySourceCandidate({ archivePath: temporaryArchivePath });
publishSourceCandidate({
archivePath,
checksumPath,
temporaryArchivePath,
temporaryChecksumPath,
});
} finally {
rmSync(temporaryRoot, { force: true, recursive: true });
}
return { archivePath, commit };
}
export async function verifySourceCandidate({ archivePath, keysPath }) {
const archiveName = basename(archivePath);
const match = archivePattern.exec(archiveName);
if (!match) throw new Error(`Unexpected ASF source archive name: ${archiveName}`);
if (!existsSync(archivePath)) throw new Error(`Source archive does not exist: ${archivePath}`);
const identity = sourceCandidateIdentity(match[1]);
const signaturePath = `${archivePath}.asc`;
const checksumPath = `${archivePath}.sha512`;
if (keysPath) {
if (!existsSync(signaturePath)) {
throw new Error(`Detached signature does not exist: ${signaturePath}`);
}
if (!existsSync(keysPath)) throw new Error(`KEYS file does not exist: ${keysPath}`);
}
if (!existsSync(checksumPath)) throw new Error(`SHA-512 file does not exist: ${checksumPath}`);
if (keysPath) verifyDetachedSignature({ archivePath, keysPath, signaturePath });
const digest = await verifySha512File(archivePath);
const entries = execTar(archivePath, ['-tzf'], {
encoding: 'utf8',
maxBuffer: maxCommandBuffer,
}).split(/\r?\n/);
validateArchiveEntries(entries, identity.rootDirectory);
validateArchiveContents(archivePath, identity, entries);
return { archivePath, digest, rootDirectory: identity.rootDirectory, version: identity.version };
}
export async function reproduceSourceCandidate({
archivePath,
repositoryRoot = defaultRepoRoot,
revision,
}) {
if (!revision) throw new Error('An immutable revision is required to reproduce a candidate');
const candidate = await verifySourceCandidate({ archivePath });
const commit = resolveCandidateCommit({
repositoryRoot,
revision,
version: candidate.version,
});
const temporaryRoot = mkdtempSync(join(tmpdir(), 'maka-asf-reproduce-'));
try {
const candidateTarPath = join(temporaryRoot, 'candidate.tar');
const reproducedTarPath = join(temporaryRoot, 'reproduced.tar');
decompressSourceArchive(archivePath, candidateTarPath);
writeSourceTar({
commit,
identity: candidate,
repositoryRoot,
tarPath: reproducedTarPath,
temporaryRoot,
});
const [candidateDigest, reproducedDigest] = await Promise.all([
sha512(candidateTarPath),
sha512(reproducedTarPath),
]);
if (candidateDigest !== reproducedDigest) {
throw new Error(`Candidate source payload does not match ${commit} rebuilt on this machine`);
}
return { commit };
} finally {
rmSync(temporaryRoot, { force: true, recursive: true });
}
}
export async function signSourceCandidate({
archivePath,
gpgHome,
keyFingerprint,
repositoryRoot = defaultRepoRoot,
revision,
}) {
if (!/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/.test(keyFingerprint)) {
throw new Error('A complete hexadecimal PGP key fingerprint is required');
}
const normalizedFingerprint = keyFingerprint.toUpperCase();
const selectedFingerprint = resolveSigningKeyFingerprint(normalizedFingerprint, gpgHome);
if (selectedFingerprint !== normalizedFingerprint) {
throw new Error(
`Selected signing key ${selectedFingerprint} does not match ${normalizedFingerprint}`,
);
}
const reproduced = await reproduceSourceCandidate({ archivePath, repositoryRoot, revision });
const signaturePath = `${archivePath}.asc`;
if (existsSync(signaturePath)) {
throw new Error(`Refusing to overwrite existing detached signature: ${signaturePath}`);
}
const temporaryRoot = mkdtempSync(join(dirname(archivePath), '.maka-asf-signature-'));
const temporarySignaturePath = join(temporaryRoot, basename(signaturePath));
try {
execFileSync(
'gpg',
[
'--armor',
'--detach-sign',
'--digest-algo',
'SHA512',
'--local-user',
normalizedFingerprint,
'--output',
temporarySignaturePath,
archivePath,
],
{ stdio: 'inherit', ...gpgHomeOption(gpgHome) },
);
chmodSync(temporarySignaturePath, 0o644);
verifyGpgSignature({ archivePath, gpgHome, signaturePath: temporarySignaturePath });
linkSync(temporarySignaturePath, signaturePath);
} catch (error) {
if (error?.code === 'EEXIST') {
throw new Error(`Refusing to overwrite existing detached signature: ${signaturePath}`, {
cause: error,
});
}
throw error;
} finally {
rmSync(temporaryRoot, { force: true, recursive: true });
}
return { commit: reproduced.commit, signaturePath };
}
function publishSourceCandidate({
archivePath,
checksumPath,
temporaryArchivePath,
temporaryChecksumPath,
}) {
let publishedArchive = false;
let publishedChecksum = false;
try {
linkSync(temporaryChecksumPath, checksumPath);
publishedChecksum = true;
linkSync(temporaryArchivePath, archivePath);
publishedArchive = true;
} catch (error) {
if (publishedArchive) rmSync(archivePath, { force: true });
if (publishedChecksum) rmSync(checksumPath, { force: true });
if (error?.code === 'EEXIST') {
const outputPath = publishedChecksum ? archivePath : checksumPath;
throw new Error(`Refusing to overwrite existing release output: ${outputPath}`, {
cause: error,
});
}
throw error;
}
}
function git(repositoryRoot, arguments_, options = {}) {
const { env, ...execOptions } = options;
return execFileSync('git', arguments_, {
cwd: repositoryRoot,
encoding: 'utf8',
maxBuffer: maxCommandBuffer,
...execOptions,
env: controlledProcessEnvironment({
excludedPrefixes: ['GIT_'],
overrides: env,
}),
});
}
function resolveCandidateCommit({ repositoryRoot, revision, version }) {
const commit = git(repositoryRoot, ['rev-parse', '--verify', `${revision}^{commit}`]).trim();
const packageJson = JSON.parse(git(repositoryRoot, ['show', `${commit}:package.json`]));
const packageLock = JSON.parse(git(repositoryRoot, ['show', `${commit}:package-lock.json`]));
validatePackageVersions({ packageJson, packageLock, version, source: `commit ${commit}` });
validateTrackedNames(repositoryRoot, commit);
return commit;
}
function writeSourceTar({ commit, identity, repositoryRoot, tarPath, temporaryRoot }) {
// git archive otherwise consults repository-local, user, and system attributes.
// The isolated Git directory makes the committed .gitattributes the only policy input.
const isolatedGitDirectory = join(temporaryRoot, 'git');
const emptyGitTemplate = join(temporaryRoot, 'git-template');
const emptyGlobalConfig = join(temporaryRoot, 'gitconfig');
mkdirSync(emptyGitTemplate);
writeFileSync(emptyGlobalConfig, '');
const isolatedEnvironment = {
GIT_ATTR_NOSYSTEM: '1',
GIT_CONFIG_GLOBAL: emptyGlobalConfig,
GIT_CONFIG_NOSYSTEM: '1',
};
git(
repositoryRoot,
['init', '--quiet', '--bare', `--template=${emptyGitTemplate}`, isolatedGitDirectory],
{ env: isolatedEnvironment },
);
const gitObjectPath = git(repositoryRoot, ['rev-parse', '--git-path', 'objects']).trim();
const objectDirectory = realpathSync(resolve(repositoryRoot, gitObjectPath));
if (objectDirectory.includes('\n') || objectDirectory.includes('\r')) {
throw new Error(`Git object directory contains an unsupported newline: ${objectDirectory}`);
}
writeFileSync(join(isolatedGitDirectory, 'objects/info/alternates'), `${objectDirectory}\n`);
git(
repositoryRoot,
[
`--git-dir=${isolatedGitDirectory}`,
'-c',
`core.attributesFile=${emptyGlobalConfig}`,
'-c',
'tar.umask=0002',
'archive',
'--format=tar',
`--prefix=${identity.rootDirectory}/`,
`--output=${tarPath}`,
commit,
],
{ env: isolatedEnvironment },
);
}
function decompressSourceArchive(archivePath, tarPath) {
const output = openSync(tarPath, 'wx', 0o600);
try {
try {
execFileSync('gzip', ['-dc', basename(archivePath)], {
cwd: dirname(archivePath),
env: controlledProcessEnvironment({ excludedNames: ['GZIP'] }),
stdio: ['ignore', output, 'inherit'],
});
} finally {
closeSync(output);
}
} catch (error) {
rmSync(tarPath, { force: true });
throw error;
}
}
function validateTrackedNames(repositoryRoot, commit) {
const names = git(repositoryRoot, ['ls-tree', '-r', '-z', '--name-only', commit])
.split('\0')
.filter(Boolean);
for (const name of names) {
if (name.includes('\n') || name.includes('\r')) {
throw new Error(`Release archives do not support newline characters in paths: ${name}`);
}
}
}
export function validatePackageVersions({ packageJson, packageLock, source, version }) {
const versions = [
['package.json', packageJson.version],
['package-lock.json', packageLock.version],
['package-lock.json packages[""]', packageLock.packages?.['']?.version],
];
for (const [name, actualVersion] of versions) {
if (actualVersion !== version) {
throw new Error(
`Version ${version} does not match ${name} in ${source}: ${String(actualVersion)}`,
);
}
}
}
async function writeSha512File(archivePath, checksumPath) {
const digest = await sha512(archivePath);
writeFileSync(checksumPath, `${digest} ${basename(archivePath)}\n`, {
encoding: 'utf8',
mode: 0o644,
});
}
function readSha512File(archivePath) {
const archiveName = basename(archivePath);
const checksumPath = `${archivePath}.sha512`;
if (!existsSync(checksumPath)) throw new Error(`SHA-512 file does not exist: ${checksumPath}`);
return parseSha512File(readFileSync(checksumPath, 'utf8'), archiveName);
}
async function verifySha512File(archivePath) {
const expected = readSha512File(archivePath);
const digest = await sha512(archivePath);
if (digest !== expected) {
throw new Error(`SHA-512 mismatch for ${basename(archivePath)}`);
}
return digest;
}
async function sha512(path) {
const hash = createHash('sha512');
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest('hex');
}
function validateArchiveContents(archivePath, identity, entries) {
const temporaryRoot = mkdtempSync(join(tmpdir(), 'maka-asf-contents-'));
try {
execTar(archivePath, ['-xzf', '-C', temporaryRoot], { stdio: 'ignore' });
const candidateRoot = join(temporaryRoot, identity.rootDirectory);
for (const entry of entries.filter(Boolean)) {
const path = join(temporaryRoot, entry);
const type = lstatSync(path);
if (!type.isDirectory() && !type.isFile()) {
throw new Error(`Unsupported archive entry type: ${entry}`);
}
}
for (const requiredFile of requiredReleaseDocuments) {
const contents = readFileSync(join(candidateRoot, requiredFile), 'utf8');
if (!contents.trim()) throw new Error(`${requiredFile} is empty`);
if (
requiredFile === 'DISCLAIMER-WIP' &&
(!contents.includes('Apache Maka') || !contents.includes('incubation'))
) {
throw new Error('DISCLAIMER-WIP does not identify Apache Maka as an incubating project');
}
}
const packageJson = JSON.parse(readFileSync(join(candidateRoot, 'package.json'), 'utf8'));
const packageLock = JSON.parse(readFileSync(join(candidateRoot, 'package-lock.json'), 'utf8'));
validatePackageVersions({
packageJson,
packageLock,
source: basename(archivePath),
version: identity.version,
});
validateNodePackageInputs(candidateRoot, identity, entries);
validateNonTextInputs(candidateRoot, identity, entries);
} finally {
rmSync(temporaryRoot, { force: true, recursive: true });
}
}
function validateNodePackageInputs(candidateRoot, identity, entries) {
const files = entries.filter((entry) => entry && !entry.endsWith('/'));
const rootPrefix = `${identity.rootDirectory}/`;
const readEntry = (entry) =>
readFileSync(join(candidateRoot, entry.slice(rootPrefix.length)), 'utf8');
const noticeLicenses = new Map();
const generatedNpmNotice = `${rootPrefix}apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt`;
if (files.includes(generatedNpmNotice)) {
const entry = generatedNpmNotice;
for (const block of readEntry(entry).split(/\n={20,}\n/u)) {
const packageKey = /^Package: (.+)$/mu.exec(block)?.[1];
const license = /^Selected license: (.+)$/mu.exec(block)?.[1];
if (!packageKey || !license) continue;
const previous = noticeLicenses.get(packageKey);
if (previous && previous !== license) {
throw new Error(`Conflicting license inventory for ${packageKey}: ${previous}, ${license}`);
}
noticeLicenses.set(packageKey, license);
}
}
const lockEntries = files.filter((name) =>
/\/(?:package-lock|npm-shrinkwrap)\.json$/u.test(name),
);
const parsedLocks = new Map();
const readLock = (entry) => {
if (parsedLocks.has(entry)) return parsedLocks.get(entry);
const lock = parseArchiveJson(readEntry(entry), entry);
if (lock.lockfileVersion !== 3 || Object.hasOwn(lock, 'dependencies')) {
throw new Error(
`Unsupported npm lockfile version ${lock.lockfileVersion} or schema: ${entry}`,
);
}
if (!lock.packages || typeof lock.packages !== 'object' || Array.isArray(lock.packages)) {
throw new Error(`Cannot safely classify package lockfile without packages: ${entry}`);
}
parsedLocks.set(entry, lock);
return lock;
};
const manifests = new Map(
files
.filter((name) => name.endsWith('/package.json'))
.map((entry) => [entry, parseArchiveJson(readEntry(entry), entry)]),
);
const rootLockEntry = `${rootPrefix}package-lock.json`;
for (const [entry, manifest] of manifests) {
if (manifest.license) validateReleaseLicense(manifest.license, entry);
else if (manifest.private !== true) {
throw new Error(`Cannot safely classify package manifest without a license: ${entry}`);
}
const dependencyNames = declaredPackageDependencies(manifest, entry);
if (dependencyNames.length > 0) {
const directory = dirname(entry);
const lockEntry = [
`${directory}/npm-shrinkwrap.json`,
`${directory}/package-lock.json`,
rootLockEntry,
].find((name) => lockEntries.includes(name));
if (!lockEntry) {
throw new Error(`Cannot safely classify package manifest ${entry} without lock provenance`);
}
const lock = readLock(lockEntry);
for (const name of dependencyNames) {
if (
!Object.keys(lock.packages).some(
(lockPath) =>
lockPath === `node_modules/${name}` || lockPath.endsWith(`/node_modules/${name}`),
)
) {
throw new Error(
`Cannot safely classify ${name} declared by ${entry} without matching lock provenance`,
);
}
}
}
}
for (const entry of files.filter((name) => /\/(?:pnpm-lock\.yaml|yarn\.lock)$/u.test(name))) {
throw new Error(`Cannot safely classify unsupported package lockfile: ${entry}`);
}
for (const entry of lockEntries) {
const lock = readLock(entry);
for (const [lockPath, dependency] of Object.entries(lock.packages)) {
if (!lockPath) continue;
if (dependency?.link === true) {
const name = lockPath.slice(lockPath.lastIndexOf('node_modules/') + 13);
const targetManifestEntry = join(dirname(entry), dependency.resolved ?? '', 'package.json');
const targetManifest = manifests.get(targetManifestEntry);
const targetLockPath = dependency.resolved;
const targetLock = targetLockPath ? lock.packages[targetLockPath] : undefined;
if (
!targetManifest ||
targetManifest.name !== name ||
!targetLock ||
targetLock.name !== targetManifest.name ||
targetLock.version !== targetManifest.version
) {
throw new Error(`Cannot safely classify workspace link ${name} in ${entry}`);
}
continue;
}
if (!lockPath.includes('node_modules/')) {
const manifestEntry = join(dirname(entry), lockPath, 'package.json');
const manifest = manifests.get(manifestEntry);
if (!manifest || manifest.name !== dependency.name) {
throw new Error(`Cannot safely classify workspace package ${lockPath} in ${entry}`);
}
if (dependency.version !== manifest.version) {
throw new Error(`Workspace version mismatch for ${lockPath} in ${entry}`);
}
if (dependency.license) {
validateReleaseLicense(dependency.license, `${lockPath} in ${entry}`);
}
continue;
}
const name = dependency.name ?? lockPath.slice(lockPath.lastIndexOf('node_modules/') + 13);
const version = dependency.version;
const packageKey = version ? `${name}@${version}` : undefined;
const license =
(packageKey ? noticeLicenses.get(packageKey) : undefined) ?? dependency.license;
if (!license) {
throw new Error(
`Cannot safely classify ${name}${version ? `@${version}` : ''} in ${entry}`,
);
}
validateReleaseLicense(license, `${name}${version ? `@${version}` : ''} in ${entry}`);
}
}
}
function declaredPackageDependencies(manifest, entry) {
const names = new Set();
for (const field of packageDependencyFields) {
const dependencies = manifest[field];
if (dependencies === undefined) continue;
if (!dependencies || typeof dependencies !== 'object' || Array.isArray(dependencies)) {
throw new Error(`Invalid ${field} in package manifest: ${entry}`);
}
for (const name of Object.keys(dependencies)) names.add(name);
}
for (const field of bundledDependencyFields) {
const dependencies = manifest[field];
if (dependencies === undefined) continue;
if (!Array.isArray(dependencies) || dependencies.some((name) => typeof name !== 'string')) {
throw new Error(`Invalid ${field} in package manifest: ${entry}`);
}
for (const name of dependencies) names.add(name);
}
return [...names];
}
function parseArchiveJson(contents, entry) {
try {
return JSON.parse(contents);
} catch (error) {
throw new Error(`Invalid JSON release input: ${entry}`, { cause: error });
}
}
function validateReleaseLicense(license, source) {
if (typeof license !== 'string' || !license.trim()) {
throw new Error(`Cannot safely classify the license for ${source}`);
}
if (categoryXLicensePattern.test(license)) {
throw new Error(`Category X dependency ${source} declares ${license}`);
}
if (!knownNonCategoryXLicenses.has(license)) {
throw new Error(`Cannot safely classify license ${license} for ${source}`);
}
}
function validateNonTextInputs(candidateRoot, identity, entries) {
const files = entries.filter((entry) => entry && !entry.endsWith('/'));
const readEntry = (entry) =>
readFileSync(join(candidateRoot, entry.slice(`${identity.rootDirectory}/`.length)));
const provenanceEntry = `${identity.rootDirectory}/docs/code-origin-audit.md`;
const provenancePatterns = files.includes(provenanceEntry)
? sourceNonTextProvenancePatterns(readEntry(provenanceEntry).toString('utf8'))
: [];
const decoder = new TextDecoder('utf-8', { fatal: true });
for (const entry of files) {
const contents = readEntry(entry);
const compiledFormat = compiledArtifactFormat(contents);
if (compiledFormat) {
throw new Error(`Compiled artifact ${entry} has forbidden ${compiledFormat} content`);
}
try {
const text = decoder.decode(contents);
const basename = entry.slice(entry.lastIndexOf('/') + 1);
const extension = extname(basename).toLowerCase();
if (
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(text) &&
(textSourceExtensions.has(extension) || textSourceBasenames.has(basename))
) {
continue;
}
} catch {
// Invalid UTF-8 continues to the candidate-owned provenance inventory.
}
const imageFormat = sourceImageFormat(contents);
const relativePath = entry.slice(`${identity.rootDirectory}/`.length);
if (
provenancePatterns.some((pattern) => sourceInventoryPatternMatches(pattern, relativePath))
) {
continue;
}
throw new Error(
`Cannot safely classify non-text release input ${entry}${imageFormat ? ` (${imageFormat})` : ''}`,
);
}
}
function compiledArtifactFormat(contents) {
const prefix = contents.subarray(0, 8);
if (prefix.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) return 'ELF';
if (prefix.subarray(0, 2).equals(Buffer.from('MZ'))) return 'PE';
if (prefix.subarray(0, 4).equals(Buffer.from([0x00, 0x61, 0x73, 0x6d]))) return 'WASM';
if (prefix.subarray(0, 4).equals(Buffer.from([0xbe, 0xba, 0xfe, 0xca]))) {
return 'Mach-O fat little-endian';
}
if (prefix.subarray(0, 4).equals(Buffer.from([0xbf, 0xba, 0xfe, 0xca]))) {
return 'Mach-O fat64 little-endian';
}
if (
[
[0xfe, 0xed, 0xfa, 0xce],
[0xfe, 0xed, 0xfa, 0xcf],
[0xce, 0xfa, 0xed, 0xfe],
[0xcf, 0xfa, 0xed, 0xfe],
[0xca, 0xfe, 0xba, 0xbe],
[0xca, 0xfe, 0xba, 0xbf],
].some((magic) => prefix.subarray(0, 4).equals(Buffer.from(magic)))
) {
return 'Mach-O';
}
if (
[
[0x50, 0x4b, 0x03, 0x04],
[0x50, 0x4b, 0x05, 0x06],
[0x50, 0x4b, 0x07, 0x08],
].some((magic) => contents.indexOf(Buffer.from(magic)) !== -1)
) {
return 'ZIP/JAR';
}
if (prefix.equals(Buffer.from('!<arch>\n'))) return 'ar archive';
if (prefix.equals(Buffer.from('!<thin>\n'))) return 'thin ar archive';
return undefined;
}
function sourceImageFormat(contents) {
const prefix = contents.subarray(0, 12);
if (prefix.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return 'PNG';
if (prefix.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) return 'JPEG';
if (
prefix
.subarray(0, 6)
.toString('ascii')
.match(/^GIF8[79]a$/u)
)
return 'GIF';
if (
prefix.subarray(0, 4).toString('ascii') === 'RIFF' &&
prefix.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return 'WebP';
}
return undefined;
}
function sourceNonTextProvenancePatterns(provenance) {
const section = provenance
.split('### Source archive non-text inventory\n', 2)[1]
?.split(/\n#{1,3} /u, 1)[0];
if (!section) return [];
return [...section.matchAll(/^- `([^`]+)`:/gmu)].map((match) => {
const pattern = match[1];
if (
pattern.startsWith('/') ||
pattern.startsWith('*') ||
pattern.startsWith('?') ||
pattern.split('/').includes('..')
) {
throw new Error(`Unsafe source archive inventory pattern: ${pattern}`);
}
return pattern;
});
}
function sourceInventoryPatternMatches(pattern, path) {
let expression = '';
for (let index = 0; index < pattern.length; index += 1) {
if (pattern.slice(index, index + 3) === '**/') {
expression += '(?:.*/)?';
index += 2;
} else if (pattern.slice(index, index + 2) === '**') {
expression += '.*';
index += 1;
} else if (pattern[index] === '*') {
expression += '[^/]*';
} else {
expression += pattern[index].replace(/[.+?^${}()|[\]\\]/u, '\\$&');
}
}
return new RegExp(`^${expression}$`, 'u').test(path);
}
function verifyDetachedSignature({ archivePath, keysPath, signaturePath }) {
if (!existsSync(keysPath)) throw new Error(`KEYS file does not exist: ${keysPath}`);
const temporaryHome = mkdtempSync(join(tmpdir(), 'maka-gpg-'));
chmodSync(temporaryHome, 0o700);
try {
execFileSync('gpg', ['--batch', '--homedir', temporaryHome, '--import', keysPath], {
stdio: 'inherit',
});
verifyGpgSignature({
archivePath,
gpgHome: temporaryHome,
signaturePath,
});
} finally {
rmSync(temporaryHome, { force: true, recursive: true });
}
}
function validateAsciiArmoredSignature(signaturePath) {
const contents = readFileSync(signaturePath);
const isAscii = contents.every(
(byte) => byte === 9 || byte === 10 || byte === 13 || (byte >= 32 && byte <= 126),
);
const text = contents.toString('utf8');
const beginMarkers = text.match(/-----BEGIN PGP SIGNATURE-----/g)?.length ?? 0;
const endMarkers = text.match(/-----END PGP SIGNATURE-----/g)?.length ?? 0;
if (
!isAscii ||
beginMarkers !== 1 ||
endMarkers !== 1 ||
!asciiArmoredSignaturePattern.test(text)
) {
throw new Error('Detached signature must be exactly one ASCII-armored PGP signature block');
}
}
function execTar(archivePath, arguments_, options) {
return execFileSync('tar', [arguments_[0], basename(archivePath), ...arguments_.slice(1)], {
cwd: dirname(archivePath),
...options,
env: controlledProcessEnvironment({
excludedNames: ['GZIP', 'TAR_OPTIONS', 'TAR_READER_OPTIONS'],
}),
});
}
export function controlledProcessEnvironment({
excludedNames = [],
excludedPrefixes = [],
overrides = {},
} = {}) {
const normalizedExcludedNames = new Set(excludedNames.map((name) => name.toUpperCase()));
const normalizedExcludedPrefixes = excludedPrefixes.map((prefix) => prefix.toUpperCase());
const environment = {};
for (const [name, value] of Object.entries(process.env)) {
const normalizedName = name.toUpperCase();
if (normalizedExcludedNames.has(normalizedName)) continue;
if (normalizedExcludedPrefixes.some((prefix) => normalizedName.startsWith(prefix))) continue;
environment[name] = value;
}
return { ...environment, ...overrides };
}
function gpgHomeOption(gpgHome) {
return gpgHome ? { env: { ...process.env, GNUPGHOME: gpgHome } } : {};
}
function resolveSigningKeyFingerprint(keyFingerprint, gpgHome) {
const output = execFileSync(
'gpg',
['--batch', '--with-colons', '--fingerprint', '--list-secret-keys', keyFingerprint],
{ encoding: 'utf8', maxBuffer: maxCommandBuffer, ...gpgHomeOption(gpgHome) },
);
let expectsPrimaryFingerprint = false;
const primaryFingerprints = [];
for (const line of output.split(/\r?\n/)) {
const fields = line.split(':');
if (fields[0] === 'sec') {
expectsPrimaryFingerprint = true;
continue;
}
if (fields[0] === 'fpr' && expectsPrimaryFingerprint) {
primaryFingerprints.push(fields[9]?.toUpperCase());
expectsPrimaryFingerprint = false;
}
}
if (primaryFingerprints.length !== 1 || !primaryFingerprints[0]) {
throw new Error(`Expected exactly one secret key for fingerprint ${keyFingerprint}`);
}
return primaryFingerprints[0];
}
export function validateGpgVerificationStatus(statusOutput) {
const statuses = statusOutput
.split(/\r?\n/)
.filter((line) => line.startsWith('[GNUPG:] '))
.map((line) => line.slice('[GNUPG:] '.length).split(' '));
const rejected = statuses.find(([status]) => rejectedGpgStatuses.has(status));
if (rejected) throw new Error(`GPG rejected the signature with status ${rejected[0]}`);
const goodSignatures = statuses.filter(([status]) => status === 'GOODSIG');
const validSignatures = statuses.filter(([status]) => status === 'VALIDSIG');
if (goodSignatures.length !== 1 || validSignatures.length !== 1) {
throw new Error('GPG did not report exactly one good, valid signature');
}
return {
fingerprint: validSignatures[0][1],
hashAlgorithm: Number(validSignatures[0][8]),
};
}
function validateSigningKeyPolicy({ fingerprint, gpgHome, hashAlgorithm }) {
if (!allowedGpgHashAlgorithms.has(hashAlgorithm)) {
throw new Error(
`Signature from ${fingerprint} must use SHA-256, SHA-384, or SHA-512; found hash algorithm ${hashAlgorithm}`,
);
}
const output = execFileSync(
'gpg',
['--batch', '--with-colons', '--with-fingerprint', '--list-keys', fingerprint],
{ encoding: 'utf8', maxBuffer: maxCommandBuffer, ...gpgHomeOption(gpgHome) },
);
let key;
for (const line of output.split(/\r?\n/)) {
const fields = line.split(':');
if (fields[0] === 'pub' || fields[0] === 'sub') {
key = {
bits: Number(fields[2]),
publicKeyAlgorithm: Number(fields[3]),
};
continue;
}
if (fields[0] !== 'fpr' || fields[9]?.toUpperCase() !== fingerprint.toUpperCase()) continue;
if (
!key ||
!rsaPublicKeyAlgorithms.has(key.publicKeyAlgorithm) ||
!Number.isInteger(key.bits) ||
key.bits < 2048
) {
throw new Error(
`Signing key ${fingerprint} must be RSA with at least 2048 bits; found algorithm ${String(key?.publicKeyAlgorithm)}, ${String(key?.bits)} bits`,
);
}
return;
}
throw new Error(`Could not resolve signing key ${fingerprint} in the selected keyring`);
}
function verifyGpgSignature({ archivePath, gpgHome, signaturePath }) {
validateAsciiArmoredSignature(signaturePath);
const result = spawnSync(
'gpg',
['--batch', '--status-fd', '1', '--verify', signaturePath, archivePath],
{
encoding: 'utf8',
maxBuffer: maxCommandBuffer,
...gpgHomeOption(gpgHome),
},
);
if (result.stderr) process.stderr.write(result.stderr);
if (result.error) throw result.error;
const signature = validateGpgVerificationStatus(result.stdout);
if (result.status !== 0)
throw new Error(`GPG verification failed with exit code ${result.status}`);
validateSigningKeyPolicy({
fingerprint: signature.fingerprint,
gpgHome,
hashAlgorithm: signature.hashAlgorithm,
});
}
function parseCommandLine(arguments_) {
const [command, ...tokens] = arguments_;
const options = new Map();
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (!token.startsWith('--')) throw new Error(`Unexpected argument: ${token}`);
const value = tokens[index + 1];
if (!value || value.startsWith('--')) throw new Error(`Missing value for ${token}`);
const name = token.slice(2);
if (options.has(name)) throw new Error(`Duplicate option: ${token}`);
options.set(name, value);
index += 1;
}
return { command, options };
}
function validateOptions(options, allowed) {
for (const name of options.keys()) {
if (!allowed.has(name)) throw new Error(`Unsupported option: --${name}`);
}
}
function requireOption(options, name) {
const value = options.get(name);
if (!value) throw new Error(`--${name} is required`);
return value;
}
async function main() {
const { command, options } = parseCommandLine(process.argv.slice(2));
if (command === 'create') {
validateOptions(options, new Set(['revision', 'version']));
const result = await createSourceCandidate({
revision: options.get('revision') ?? 'HEAD',
version: requireOption(options, 'version'),
});
console.log(`Created ${result.archivePath}`);
console.log(`Commit ${result.commit}`);
return;
}
if (command === 'verify') {
validateOptions(options, new Set(['artifact', 'keys']));
const result = await verifySourceCandidate({
archivePath: resolve(requireOption(options, 'artifact')),
keysPath: options.get('keys') ? resolve(options.get('keys')) : undefined,
});
console.log(`Verified ${result.archivePath}`);
console.log(`SHA-512 ${result.digest}`);
return;
}
if (command === 'sign') {
validateOptions(options, new Set(['artifact', 'key', 'revision']));
const result = await signSourceCandidate({
archivePath: resolve(requireOption(options, 'artifact')),
keyFingerprint: requireOption(options, 'key'),
revision: requireOption(options, 'revision'),
});
console.log(`Reproduced commit ${result.commit}`);
console.log(`Created ${result.signaturePath}`);
return;
}
throw new Error('Usage: asf-source-release.mjs <create|verify|sign> [options]');
}
if (
process.argv[1] &&
realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1])
) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
}