| import assert from 'node:assert/strict'; |
| import { execFileSync } from 'node:child_process'; |
| import { createHash } from 'node:crypto'; |
| import { |
| chmodSync, |
| copyFileSync, |
| existsSync, |
| mkdtempSync, |
| mkdirSync, |
| readFileSync, |
| rmSync, |
| writeFileSync, |
| } from 'node:fs'; |
| import { tmpdir } from 'node:os'; |
| import { basename, dirname, join } from 'node:path'; |
| import { describe, test } from 'node:test'; |
| import { |
| createSourceCandidate, |
| controlledProcessEnvironment, |
| parseSha512File, |
| reproduceSourceCandidate, |
| signSourceCandidate, |
| sourceCandidateIdentity, |
| validateArchiveEntries, |
| validateGpgVerificationStatus, |
| validatePackageVersions, |
| verifySourceCandidate, |
| } from './asf-source-release.mjs'; |
| |
| describe('ASF source release identity', () => { |
| test('uses the incubating source distribution name for plain versions only', () => { |
| assert.deepEqual(sourceCandidateIdentity('0.1.12'), { |
| archiveName: 'apache-maka-0.1.12-incubating-src.tar.gz', |
| rootDirectory: 'apache-maka-0.1.12-incubating', |
| version: '0.1.12', |
| }); |
| assert.throws(() => sourceCandidateIdentity('0.1.12-incubating'), /Invalid release version/); |
| }); |
| }); |
| |
| describe('ASF source release verification', () => { |
| test('validates checksum identity and archive boundaries', () => { |
| const name = 'apache-maka-0.1.12-incubating-src.tar.gz'; |
| assert.equal(parseSha512File(`${'a'.repeat(128)} ${name}\n`, name), 'a'.repeat(128)); |
| assert.throws(() => parseSha512File(`${'a'.repeat(128)} another.tar.gz\n`, name), /expected/); |
| |
| const root = 'apache-maka-0.1.12-incubating'; |
| assert.doesNotThrow(() => |
| validateArchiveEntries( |
| [ |
| `${root}/`, |
| `${root}/DISCLAIMER-WIP`, |
| `${root}/LICENSE`, |
| `${root}/NOTICE`, |
| `${root}/package-lock.json`, |
| `${root}/package.json`, |
| `${root}/src/index.ts`, |
| ], |
| root, |
| ), |
| ); |
| assert.throws( |
| () => |
| validateArchiveEntries( |
| [ |
| `${root}/DISCLAIMER-WIP`, |
| `${root}/LICENSE`, |
| `${root}/NOTICE`, |
| `${root}/package-lock.json`, |
| `${root}/package.json`, |
| `${root}/.maka-shots/review.png`, |
| `${root}/node_modules/dependency/index.js`, |
| ], |
| root, |
| ), |
| /Forbidden archive entry/, |
| ); |
| }); |
| |
| test('requires one current valid GPG signature status', () => { |
| const fingerprint = 'A'.repeat(40); |
| assert.deepEqual( |
| validateGpgVerificationStatus( |
| `[GNUPG:] GOODSIG ABCDEF0123456789 Release Test\n[GNUPG:] VALIDSIG ${fingerprint} 2026-08-20 1787193600 0 4 0 1 10 00 ${fingerprint}\n`, |
| ), |
| { fingerprint, hashAlgorithm: 10 }, |
| ); |
| for (const status of ['EXPKEYSIG', 'EXPSIG', 'KEYEXPIRED', 'KEYREVOKED', 'REVKEYSIG']) { |
| assert.throws( |
| () => |
| validateGpgVerificationStatus( |
| `[GNUPG:] ${status} ABCDEF0123456789 Release Test\n[GNUPG:] GOODSIG ABCDEF0123456789 Release Test\n[GNUPG:] VALIDSIG ${fingerprint} 2026-08-20 1787193600 0 4 0 1 10 00 ${fingerprint}\n`, |
| ), |
| new RegExp(status), |
| ); |
| } |
| }); |
| |
| test('requires package and lockfile versions to share one identity', () => { |
| const packageJson = { version: '0.1.12' }; |
| const packageLock = { packages: { '': { version: '0.1.12' } }, version: '0.1.12' }; |
| assert.doesNotThrow(() => |
| validatePackageVersions({ packageJson, packageLock, source: 'fixture', version: '0.1.12' }), |
| ); |
| assert.throws( |
| () => |
| validatePackageVersions({ |
| packageJson, |
| packageLock: { ...packageLock, version: '9.9.9' }, |
| source: 'fixture', |
| version: '0.1.12', |
| }), |
| /package-lock\.json.*9\.9\.9/, |
| ); |
| }); |
| |
| test('filters controlled environment names case-insensitively', async () => { |
| await withEnvironmentVariables( |
| { gIt_Prefix_Probe: 'prefix', mAkA_Exact_Probe: 'exact' }, |
| () => { |
| const environment = controlledProcessEnvironment({ |
| excludedNames: ['MAKA_EXACT_PROBE'], |
| excludedPrefixes: ['GIT_'], |
| overrides: { GIT_CONFIG_NOSYSTEM: '1' }, |
| }); |
| const names = Object.keys(environment).map((name) => name.toUpperCase()); |
| assert.equal(names.includes('MAKA_EXACT_PROBE'), false); |
| assert.equal(names.includes('GIT_PREFIX_PROBE'), false); |
| assert.equal(environment.GIT_CONFIG_NOSYSTEM, '1'); |
| }, |
| ); |
| }); |
| |
| test('requires a signature before reading unauthenticated candidate metadata', async () => { |
| const temporaryRoot = mkdtempSync(join(tmpdir(), 'maka-asf-auth-order-test-')); |
| const identity = sourceCandidateIdentity('0.1.12'); |
| const archivePath = join(temporaryRoot, identity.archiveName); |
| try { |
| writeFileSync(archivePath, 'not a tar archive\n'); |
| const keysPath = join(temporaryRoot, 'KEYS'); |
| writeFileSync(keysPath, ''); |
| |
| await assert.rejects( |
| () => verifySourceCandidate({ archivePath, keysPath }), |
| /Detached signature does not exist/, |
| ); |
| } finally { |
| rmSync(temporaryRoot, { force: true, recursive: true }); |
| } |
| }); |
| |
| test('creates reproducible candidates from committed files only', async () => { |
| const temporaryRoot = mkdtempSync(join(tmpdir(), 'maka-asf-source-test-')); |
| const repositoryRoot = join(temporaryRoot, 'repository'); |
| const firstOutput = join(temporaryRoot, 'first'); |
| const hostileOutput = join(temporaryRoot, 'hostile'); |
| mkdirSync(repositoryRoot, { recursive: true }); |
| try { |
| writeReleaseContents(repositoryRoot, { includeAttributes: true }); |
| writeFileSync(join(repositoryRoot, '.gitignore'), 'untracked.txt\n'); |
| writeFileSync(join(repositoryRoot, 'README.md'), 'release fixture\n'.repeat(4096)); |
| writeFileSync(join(repositoryRoot, 'untracked.txt'), 'must not be released\n'); |
| mkdirSync(join(repositoryRoot, '.claude')); |
| mkdirSync(join(repositoryRoot, '.maka-shots')); |
| writeFileSync(join(repositoryRoot, '.claude/launch.json'), '{}\n'); |
| writeFileSync(join(repositoryRoot, '.maka-shots/review.png'), 'review evidence\n'); |
| writeFileSync(join(repositoryRoot, 'maka-proposal-zh-review.txt'), 'working notes\n'); |
| |
| git(repositoryRoot, ['init']); |
| git(repositoryRoot, [ |
| 'add', |
| 'package.json', |
| 'package-lock.json', |
| 'DISCLAIMER-WIP', |
| 'LICENSE', |
| 'NOTICE', |
| '.gitignore', |
| '.gitattributes', |
| 'README.md', |
| '.claude/launch.json', |
| '.maka-shots/review.png', |
| 'maka-proposal-zh-review.txt', |
| ]); |
| git(repositoryRoot, [ |
| '-c', |
| 'user.name=ASF Release Test', |
| '-c', |
| 'user.email=release-test@example.invalid', |
| 'commit', |
| '-m', |
| 'test fixture', |
| ]); |
| |
| const first = await createSourceCandidate({ |
| outputDirectory: firstOutput, |
| repositoryRoot, |
| version: '0.1.12', |
| }); |
| await assert.rejects( |
| () => |
| createSourceCandidate({ |
| outputDirectory: firstOutput, |
| repositoryRoot, |
| version: '0.1.12', |
| }), |
| /Refusing to overwrite existing release output/, |
| ); |
| git(repositoryRoot, ['config', 'tar.umask', '0077']); |
| writeFileSync(join(repositoryRoot, '.git/info/attributes'), 'README.md export-ignore\n'); |
| |
| const ambientTemplate = join(temporaryRoot, 'ambient-template'); |
| mkdirSync(join(ambientTemplate, 'info'), { recursive: true }); |
| writeFileSync(join(ambientTemplate, 'info/attributes'), 'README.md export-ignore\n'); |
| |
| const ambientRepository = join(temporaryRoot, 'ambient-repository'); |
| git(temporaryRoot, ['clone', '--quiet', repositoryRoot, ambientRepository]); |
| writeFileSync(join(ambientRepository, 'README.md'), 'ambient repository\n'); |
| git(ambientRepository, ['add', 'README.md']); |
| git(ambientRepository, [ |
| '-c', |
| 'user.name=ASF Release Test', |
| '-c', |
| 'user.email=release-test@example.invalid', |
| 'commit', |
| '-m', |
| 'change ambient repository', |
| ]); |
| const hostile = await withEnvironmentVariables( |
| { |
| GIT_DIR: join(ambientRepository, '.git'), |
| GIT_TEMPLATE_DIR: ambientTemplate, |
| GZIP: '-l', |
| }, |
| () => |
| createSourceCandidate({ |
| outputDirectory: hostileOutput, |
| repositoryRoot, |
| version: '0.1.12', |
| }), |
| ); |
| assert.deepEqual(readFileSync(first.archivePath), readFileSync(hostile.archivePath)); |
| assert.equal(hostile.commit, first.commit); |
| assert.deepEqual( |
| readFileSync(`${first.archivePath}.sha512`), |
| readFileSync(`${hostile.archivePath}.sha512`), |
| ); |
| await assert.doesNotReject(() => verifySourceCandidate({ archivePath: first.archivePath })); |
| |
| const entries = execFileSync('tar', ['-tzf', basename(first.archivePath)], { |
| cwd: dirname(first.archivePath), |
| encoding: 'utf8', |
| }); |
| assert.doesNotMatch(entries, /untracked\.txt/); |
| assert.doesNotMatch(entries, /\.claude|\.maka-shots|maka-proposal-zh-review/); |
| assert.match(entries, /README\.md/); |
| |
| const originalCompressedBytes = readFileSync(first.archivePath); |
| rewriteArchiveCompression(first.archivePath, 1); |
| assert.notDeepEqual(readFileSync(first.archivePath), originalCompressedBytes); |
| await assert.doesNotReject(() => |
| reproduceSourceCandidate({ |
| archivePath: first.archivePath, |
| repositoryRoot, |
| revision: first.commit, |
| }), |
| ); |
| writeFileSync(join(repositoryRoot, 'README.md'), 'different committed payload\n'); |
| git(repositoryRoot, ['add', 'README.md']); |
| commitFixture(repositoryRoot, 'change candidate payload'); |
| await assert.rejects( |
| () => |
| reproduceSourceCandidate({ |
| archivePath: first.archivePath, |
| repositoryRoot, |
| revision: 'HEAD', |
| }), |
| /Candidate source payload does not match/, |
| ); |
| |
| const gpgHome = join(temporaryRoot, 'gnupg'); |
| const keysPath = join(temporaryRoot, 'KEYS'); |
| const fingerprint = generateSigningKey({ |
| algorithm: 'rsa2048', |
| gpgHome, |
| identity: 'ASF Release Test <release-test@example.invalid>', |
| signingSubkeyAlgorithm: 'rsa2048', |
| usage: 'cert', |
| }); |
| writeFileSync(join(gpgHome, 'gpg.conf'), 'digest-algo SHA1\n'); |
| await assert.doesNotReject(() => |
| signSourceCandidate({ |
| archivePath: first.archivePath, |
| gpgHome, |
| keyFingerprint: fingerprint, |
| repositoryRoot, |
| revision: first.commit, |
| }), |
| ); |
| exportPublicKey({ fingerprint, gpgHome, keysPath }); |
| await assert.doesNotReject(() => |
| verifySourceCandidate({ archivePath: first.archivePath, keysPath }), |
| ); |
| |
| rmSync(`${first.archivePath}.asc`); |
| execFileSync( |
| 'gpg', |
| [ |
| '--batch', |
| '--homedir', |
| gpgHome, |
| '--digest-algo', |
| 'SHA512', |
| '--detach-sign', |
| '--output', |
| `${first.archivePath}.asc`, |
| first.archivePath, |
| ], |
| { stdio: 'ignore' }, |
| ); |
| const checksum = readFileSync(`${first.archivePath}.sha512`); |
| writeFileSync(`${first.archivePath}.sha512`, 'not a checksum\n'); |
| await assert.rejects( |
| () => verifySourceCandidate({ archivePath: first.archivePath, keysPath }), |
| /ASCII-armored/, |
| ); |
| writeFileSync(`${first.archivePath}.sha512`, checksum); |
| |
| rmSync(`${first.archivePath}.asc`); |
| execFileSync( |
| 'gpg', |
| [ |
| '--batch', |
| '--homedir', |
| gpgHome, |
| '--armor', |
| '--digest-algo', |
| 'SHA1', |
| '--detach-sign', |
| '--output', |
| `${first.archivePath}.asc`, |
| first.archivePath, |
| ], |
| { stdio: 'ignore' }, |
| ); |
| await assert.rejects( |
| () => verifySourceCandidate({ archivePath: first.archivePath, keysPath }), |
| /must use SHA-256, SHA-384, or SHA-512/, |
| ); |
| |
| rmSync(`${first.archivePath}.asc`); |
| // Keep the homedir short enough for GPG agent socket paths on macOS. |
| const ed25519Home = join(temporaryRoot, 'ed'); |
| const ed25519Fingerprint = generateSigningKey({ |
| algorithm: 'ed25519', |
| gpgHome: ed25519Home, |
| identity: 'ASF Ed25519 Release Test <release-test@example.invalid>', |
| }); |
| await assert.rejects( |
| () => |
| signSourceCandidate({ |
| archivePath: first.archivePath, |
| gpgHome: ed25519Home, |
| keyFingerprint: ed25519Fingerprint, |
| repositoryRoot, |
| revision: first.commit, |
| }), |
| /must be RSA with at least 2048 bits/, |
| ); |
| assert.equal(existsSync(`${first.archivePath}.asc`), false); |
| |
| const rsa1024Home = join(temporaryRoot, 'r'); |
| const rsa1024Fingerprint = generateSigningKey({ |
| algorithm: 'rsa2048', |
| gpgHome: rsa1024Home, |
| identity: 'ASF RSA-1024 Subkey Test <release-test@example.invalid>', |
| signingSubkeyAlgorithm: 'rsa1024', |
| usage: 'cert', |
| }); |
| await assert.rejects( |
| () => |
| signSourceCandidate({ |
| archivePath: first.archivePath, |
| gpgHome: rsa1024Home, |
| keyFingerprint: rsa1024Fingerprint, |
| repositoryRoot, |
| revision: first.commit, |
| }), |
| /must be RSA with at least 2048 bits/, |
| ); |
| assert.equal(existsSync(`${first.archivePath}.asc`), false); |
| } finally { |
| rmSync(temporaryRoot, { force: true, recursive: true }); |
| } |
| }); |
| |
| test('does not let ambient tar options control archive verification', async () => { |
| const temporaryRoot = mkdtempSync(join(tmpdir(), 'maka-asf-tar-options-test-')); |
| const identity = sourceCandidateIdentity('0.1.12'); |
| const sourceRoot = join(temporaryRoot, identity.rootDirectory); |
| const archivePath = join(temporaryRoot, identity.archiveName); |
| try { |
| writeReleaseContents(sourceRoot); |
| mkdirSync(join(sourceRoot, '.agents'), { recursive: true }); |
| writeFileSync(join(sourceRoot, '.agents/secret.txt'), 'must not be released\n'); |
| execFileSync('tar', ['-czf', archivePath, identity.rootDirectory], { |
| cwd: temporaryRoot, |
| stdio: 'ignore', |
| }); |
| const digest = createHash('sha512').update(readFileSync(archivePath)).digest('hex'); |
| writeFileSync(`${archivePath}.sha512`, `${digest} ${identity.archiveName}\n`); |
| |
| for (const [name, value] of Object.entries({ |
| GZIP: '-l', |
| TAR_OPTIONS: `--exclude=${identity.rootDirectory}/.agents`, |
| TAR_READER_OPTIONS: 'tar:hdrcharset=BOGUS', |
| })) { |
| await withEnvironmentVariables({ [name]: value }, () => |
| assert.rejects(() => verifySourceCandidate({ archivePath }), /Forbidden archive entry/), |
| ); |
| } |
| } finally { |
| rmSync(temporaryRoot, { force: true, recursive: true }); |
| } |
| }); |
| }); |
| |
| function git(repositoryRoot, arguments_) { |
| execFileSync('git', arguments_, { cwd: repositoryRoot, stdio: 'ignore' }); |
| } |
| |
| function writeReleaseContents(root, { includeAttributes = false } = {}) { |
| mkdirSync(root, { recursive: true }); |
| writeFileSync( |
| join(root, 'package.json'), |
| `${JSON.stringify({ name: 'maka', version: '0.1.12' }, null, 2)}\n`, |
| ); |
| writeFileSync( |
| join(root, 'package-lock.json'), |
| `${JSON.stringify({ lockfileVersion: 3, name: 'maka', packages: { '': { name: 'maka', version: '0.1.12' } }, version: '0.1.12' }, null, 2)}\n`, |
| ); |
| writeFileSync( |
| join(root, 'DISCLAIMER-WIP'), |
| 'Apache Maka is undergoing incubation at The Apache Software Foundation.\n', |
| ); |
| writeFileSync(join(root, 'LICENSE'), 'Apache License, Version 2.0\n'); |
| writeFileSync(join(root, 'NOTICE'), 'Apache Maka\n'); |
| if (includeAttributes) { |
| copyFileSync(join(import.meta.dirname, '../.gitattributes'), join(root, '.gitattributes')); |
| } |
| } |
| |
| function commitFixture(repositoryRoot, message) { |
| git(repositoryRoot, [ |
| '-c', |
| 'user.name=ASF Release Test', |
| '-c', |
| 'user.email=release-test@example.invalid', |
| 'commit', |
| '-m', |
| message, |
| ]); |
| } |
| |
| async function withEnvironmentVariables(values, callback) { |
| const previous = new Map(Object.keys(values).map((name) => [name, process.env[name]])); |
| Object.assign(process.env, values); |
| try { |
| return await callback(); |
| } finally { |
| for (const [name, value] of previous) { |
| if (value === undefined) delete process.env[name]; |
| else process.env[name] = value; |
| } |
| } |
| } |
| |
| function rewriteArchiveCompression(archivePath, level) { |
| const tarPath = archivePath.slice(0, -3); |
| execFileSync('gzip', ['-d', archivePath], { stdio: 'ignore' }); |
| execFileSync('gzip', ['-n', `-${level}`, tarPath], { stdio: 'ignore' }); |
| const digest = createHash('sha512').update(readFileSync(archivePath)).digest('hex'); |
| writeFileSync(`${archivePath}.sha512`, `${digest} ${basename(archivePath)}\n`); |
| } |
| |
| function generateSigningKey({ |
| algorithm, |
| gpgHome, |
| identity, |
| signingSubkeyAlgorithm, |
| usage = 'sign', |
| }) { |
| mkdirSync(gpgHome, { mode: 0o700 }); |
| chmodSync(gpgHome, 0o700); |
| execFileSync( |
| 'gpg', |
| [ |
| '--batch', |
| '--homedir', |
| gpgHome, |
| '--pinentry-mode', |
| 'loopback', |
| '--passphrase', |
| '', |
| '--quick-generate-key', |
| identity, |
| algorithm, |
| usage, |
| '1d', |
| ], |
| { stdio: 'ignore' }, |
| ); |
| const fingerprint = execFileSync( |
| 'gpg', |
| ['--batch', '--homedir', gpgHome, '--with-colons', '--fingerprint', '--list-secret-keys'], |
| { encoding: 'utf8' }, |
| ) |
| .split(/\r?\n/) |
| .find((line) => line.startsWith('fpr:')) |
| ?.split(':')[9]; |
| assert.match(fingerprint, /^[0-9A-F]{40}$/); |
| if (signingSubkeyAlgorithm) { |
| execFileSync( |
| 'gpg', |
| [ |
| '--batch', |
| '--homedir', |
| gpgHome, |
| '--pinentry-mode', |
| 'loopback', |
| '--passphrase', |
| '', |
| '--quick-add-key', |
| fingerprint, |
| signingSubkeyAlgorithm, |
| 'sign', |
| '1d', |
| ], |
| { stdio: 'ignore' }, |
| ); |
| } |
| return fingerprint; |
| } |
| |
| function exportPublicKey({ fingerprint, gpgHome, keysPath }) { |
| execFileSync( |
| 'gpg', |
| ['--batch', '--homedir', gpgHome, '--armor', '--output', keysPath, '--export', fingerprint], |
| { stdio: 'ignore' }, |
| ); |
| } |