Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
1132 lines
39 KiB
JavaScript
1132 lines
39 KiB
JavaScript
/*
|
|
* 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;
|
|
});
|
|
}
|