/* * 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 { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { cpSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs'; import { createServer } from 'node:net'; import { tmpdir, userInfo } from 'node:os'; import { isAbsolute, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { npmSpawnOptions } from './npm-spawn.mjs'; const PROCESS_TIMEOUT_MS = 90_000; const MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const MAX_TARBALL_BYTES = 64 * 1024 * 1024; const fixturePath = fileURLToPath( new URL('./released-cli-state-root-fixture.mjs', import.meta.url), ); const qualificationPath = fileURLToPath(import.meta.url); const SUDO_BWRAP_ENV = 'MAKA_QUALIFICATION_BWRAP_USE_SUDO'; export function parseQualificationArgs(argv) { const allowedNames = new Set([ '--source', '--target', '--source-sha256', '--target-sha256', '--expect-epoch-relation', ]); const values = new Map(); for (let index = 0; index < argv.length; index += 2) { const name = argv[index]; const value = argv[index + 1]; if (!name?.startsWith('--') || value === undefined) { throw new Error('Qualification arguments must be --name value pairs'); } if (!allowedNames.has(name)) throw new Error(`Unknown qualification argument: ${name}`); if (values.has(name)) throw new Error(`Duplicate qualification argument: ${name}`); values.set(name, value); } const source = requireAbsolutePath(values, '--source'); const target = requireAbsolutePath(values, '--target'); const sourceSha256 = requireSha256(values, '--source-sha256'); const targetSha256 = requireSha256(values, '--target-sha256'); const expectedEpochRelation = values.get('--expect-epoch-relation') ?? 'any'; if (!['same', 'different', 'any'].includes(expectedEpochRelation)) { throw new Error('Expected epoch relation must be same, different, or any'); } return { source, target, sourceSha256, targetSha256, expectedEpochRelation }; } export function assertExpectedEpochRelation(sourceEpoch, targetEpoch, expected) { if (!Number.isSafeInteger(sourceEpoch) || !Number.isSafeInteger(targetEpoch)) { throw new Error('Release compatibility epochs must be safe integers'); } const actual = sourceEpoch === targetEpoch ? 'same' : 'different'; if (expected !== 'any' && expected !== actual) { throw new Error(`Expected ${expected} compatibility epochs, found ${actual}`); } return actual; } export function sha256File(path) { return createHash('sha256').update(readFileSync(path)).digest('hex'); } export function qualificationSandboxInvocation({ args, account, useSudo }) { if (!useSudo) return { command: 'bwrap', args }; const separator = args.indexOf('--'); if (separator === -1) throw new Error('Qualification sandbox command is missing'); return { command: 'sudo', args: [ '--non-interactive', '--', '/usr/bin/bwrap', ...args.slice(0, separator), '--cap-add', 'CAP_SETUID', '--cap-add', 'CAP_SETGID', '--cap-add', 'CAP_SETPCAP', '--', '/usr/bin/setpriv', '--regid', String(account.gid), '--reuid', String(account.uid), '--clear-groups', '--inh-caps=-all', '--ambient-caps=-all', '--bounding-set=-all', '--no-new-privs', ...args.slice(separator + 1), ], }; } export async function qualifyReleasedCliStateRoot(input) { if (process.platform !== 'linux') { throw new Error('Released State Root qualification currently requires Linux'); } assertCommandAvailable('bwrap'); assertCommandAvailable('timeout'); const useSudo = parseSudoBwrapEnvironment(process.env[SUDO_BWRAP_ENV]); if (useSudo) { assertCommandAvailable('sudo'); assertCommandAvailable('/usr/bin/setpriv'); } assertTarballDigest(input.source, input.sourceSha256, 'source'); assertTarballDigest(input.target, input.targetSha256, 'target'); const scope = mkdtempSync(join(tmpdir(), 'maka-released-state-root-')); try { const sandbox = prepareSandbox(scope); const source = installArtifact({ role: 'source', tarball: input.source, scope, sandbox, }); const target = installArtifact({ role: 'target', tarball: input.target, scope, sandbox, }); const epochRelation = assertExpectedEpochRelation( source.compatibilityEpoch, target.compatibilityEpoch, input.expectedEpochRelation, ); const innerInputPath = join(scope, 'qualification-input.json'); writeFileSync( innerInputPath, `${JSON.stringify({ scope, source, target, sourceSha256: input.sourceSha256, targetSha256: input.targetSha256, epochRelation, })}\n`, { mode: 0o600 }, ); return runQualificationSandbox({ innerInputPath, sandbox, scope, useSudo }); } finally { rmSync(scope, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } } async function qualifyInstalledArtifacts(input) { const { scope, source, target } = input; const rootPath = join(scope, 'state-root'); const goldenPath = join(scope, 'golden-root'); mkdirSync(rootPath, { recursive: true }); const seeded = runFixture({ action: 'seed', artifact: source, rootPath, scope }); assertFacts(seeded); cpSync(rootPath, goldenPath, { recursive: true, force: true }); const sourceReady = await runInstalledRuntimeHost({ artifact: source, rootPath, scope }); const sourceFacts = runFixture({ action: 'inspect', artifact: source, rootPath, scope }); assertSameFacts(seeded, sourceFacts, 'source self-reopen'); restoreGolden(rootPath, goldenPath); const writerFence = await proveWriterFence({ source, target, rootPath, scope }); restoreGolden(rootPath, goldenPath); const targetReady = await runInstalledRuntimeHost({ artifact: target, rootPath, scope }); const targetFacts = runFixture({ action: 'inspect', artifact: target, rootPath, scope }); assertSameFacts(seeded, targetFacts, 'target transition'); return { schemaVersion: 1, source: artifactEvidence(source, input.sourceSha256), target: artifactEvidence(target, input.targetSha256), epochRelation: input.epochRelation, facts: seeded, checks: { sourceSelfReopen: { kind: 'passed', host: sourceReady }, concurrentWriterFence: writerFence, targetTransition: { kind: 'passed', host: targetReady }, }, claims: { rollback: 'not_claimed', downgrade: 'unsupported', externalNpmReconciliation: 'not_qualified_by_this_harness', }, }; } function requireAbsolutePath(values, name) { const value = values.get(name); if (!value || !isAbsolute(value)) throw new Error(`${name} must be an absolute path`); return resolve(value); } function requireSha256(values, name) { const value = values.get(name); if (!value || !/^[a-f0-9]{64}$/u.test(value)) { throw new Error(`${name} must be a lowercase SHA-256`); } return value; } function assertTarballDigest(path, expected, role) { const stat = statSync(path); if (!stat.isFile() || stat.size > MAX_TARBALL_BYTES) { throw new Error(`The ${role} release tarball exceeds the qualification boundary`); } const actual = sha256File(path); if (actual !== expected) { throw new Error(`The ${role} release tarball SHA-256 does not match`); } } function assertCommandAvailable(command) { const result = spawnSync(command, ['--version'], { encoding: 'utf8' }); if (result.error?.code === 'ENOENT') throw new Error(`${command} is required`); if (result.status !== 0) throw new Error(`${command} is unavailable`); } function prepareSandbox(scope) { const account = userInfo(); const home = join(scope, 'home'); const temp = join(scope, 'tmp'); const etc = join(scope, 'etc'); for (const path of [home, temp, etc]) mkdirSync(path, { recursive: true }); const passwd = join(etc, 'passwd'); const group = join(etc, 'group'); writeFileSync( passwd, `maka-qualification:x:${account.uid}:${account.gid}:Maka qualification:${home}:/bin/sh\n`, ); writeFileSync(group, `maka-qualification:x:${account.gid}:\n`); return { account, home, temp, passwd, group, environment: { ...process.env, HOME: home, XDG_CACHE_HOME: join(home, '.cache'), XDG_CONFIG_HOME: join(home, '.config'), XDG_DATA_HOME: join(home, '.local/share'), TMPDIR: temp, }, }; } function parseSudoBwrapEnvironment(value) { if (value === undefined || value === '') return false; if (value === '1') return true; throw new Error(`${SUDO_BWRAP_ENV} must be 1 when set`); } function installArtifact({ role, tarball, scope, sandbox }) { const prefix = join(scope, `${role}-prefix`); const cache = join(scope, `${role}-npm-cache`); const result = spawnSync( 'npm', [ 'install', '--global', '--prefix', prefix, '--cache', cache, '--offline', '--no-audit', '--no-fund', tarball, ], npmSpawnOptions({ cwd: scope, env: { ...sandbox.environment, npm_config_registry: 'http://127.0.0.1:9/', }, encoding: 'utf8', }), ); if (result.status !== 0) { throw new Error(`Unable to install the ${role} release tarball: ${result.stderr}`); } const packageRoot = join(prefix, 'lib/node_modules/maka-agent'); const manifest = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')); const protocolPath = join(packageRoot, 'node_modules/@maka/runtime-host/dist/protocol/index.js'); const protocol = readFileSync(protocolPath, 'utf8'); const epoch = protocol.match(/RUNTIME_HOST_COMPATIBILITY_EPOCH\s*=\s*(\d+)/u)?.[1]; if (!epoch) throw new Error(`The ${role} release has no compatibility epoch`); const cliPath = join(packageRoot, 'dist/cli.js'); const versionResult = spawnSync(process.execPath, [cliPath, '--version'], { cwd: scope, env: sandbox.environment, encoding: 'utf8', maxBuffer: MAX_OUTPUT_BYTES, timeout: PROCESS_TIMEOUT_MS, }); if (versionResult.status !== 0) { throw new Error(`The ${role} CLI version check failed: ${versionResult.stderr}`); } const version = versionResult.stdout.trim(); if (version !== manifest.version) { throw new Error(`The ${role} CLI reports ${version}; expected ${manifest.version}`); } return { role, prefix, packageRoot, cliPath, version, compatibilityEpoch: Number(epoch) }; } export function qualificationSandboxArgs({ innerInputPath, sandbox, scope }) { return [ '--die-with-parent', '--ro-bind', '/', '/', '--tmpfs', '/tmp', '--chmod', '1777', '/tmp', '--bind', scope, scope, '--ro-bind', sandbox.passwd, '/etc/passwd', '--ro-bind', sandbox.group, '/etc/group', '--setenv', 'HOME', sandbox.home, '--setenv', 'XDG_CACHE_HOME', sandbox.environment.XDG_CACHE_HOME, '--setenv', 'XDG_CONFIG_HOME', sandbox.environment.XDG_CONFIG_HOME, '--setenv', 'XDG_DATA_HOME', sandbox.environment.XDG_DATA_HOME, '--setenv', 'TMPDIR', sandbox.temp, '--chdir', scope, '--', process.execPath, qualificationPath, '--inner', innerInputPath, ]; } function runQualificationSandbox({ innerInputPath, sandbox, scope, useSudo }) { const invocation = qualificationSandboxInvocation({ args: qualificationSandboxArgs({ innerInputPath, sandbox, scope }), account: sandbox.account, useSudo, }); const result = spawnSync(invocation.command, invocation.args, { cwd: scope, env: sandbox.environment, encoding: 'utf8', maxBuffer: MAX_OUTPUT_BYTES, timeout: PROCESS_TIMEOUT_MS * 4, }); if (result.status !== 0) { throw new Error(`Qualification sandbox failed: ${result.stderr || result.stdout}`); } return parseLastJsonLine(result.stdout); } function runFixture({ action, artifact, rootPath, scope }) { const result = spawnSync( process.execPath, [fixturePath, '--action', action, '--package-root', artifact.packageRoot, '--root', rootPath], { cwd: scope, env: process.env, encoding: 'utf8', maxBuffer: MAX_OUTPUT_BYTES, timeout: PROCESS_TIMEOUT_MS, }, ); if (result.status !== 0) { throw new Error(`Released fixture failed: ${result.stderr || result.stdout}`); } return parseLastJsonLine(result.stdout); } async function proveWriterFence({ source, target, rootPath, scope }) { const result = spawnSync( process.execPath, [ fixturePath, '--action', 'fence', '--package-root', source.packageRoot, '--root', rootPath, '--target-package-root', target.packageRoot, ], { cwd: scope, env: process.env, encoding: 'utf8', maxBuffer: MAX_OUTPUT_BYTES, timeout: PROCESS_TIMEOUT_MS, }, ); if (result.status !== 0) { throw new Error(`Released writer-fence fixture failed: ${result.stderr || result.stdout}`); } const attempted = parseLastJsonLine(result.stdout); if (attempted.kind !== 'writer_fenced') { throw new Error('The target release acquired a concurrently held State Root writer'); } return { kind: 'passed', rootId: attempted.rootId }; } async function runInstalledRuntimeHost({ artifact, rootPath, scope }) { const configPath = join(scope, `${artifact.role}-runtime-host-service.json`); writeFileSync( configPath, `${JSON.stringify({ schemaVersion: 2, rootPath, projectDirectoryRoots: [{ label: 'qualification', path: scope }], websocket: { host: '127.0.0.1', port: await allocateLoopbackPort(), path: '/runtime-host', }, launch: { nodePath: process.execPath, cliPath: artifact.cliPath }, })}\n`, { mode: 0o600 }, ); const result = spawnSync( 'timeout', [ '--signal=INT', '--kill-after=15s', '--preserve-status', '10s', process.execPath, artifact.cliPath, 'runtime-host', 'serve', '--managed-service-config', configPath, '--json', ], { cwd: scope, env: process.env, encoding: 'utf8', maxBuffer: MAX_OUTPUT_BYTES, timeout: PROCESS_TIMEOUT_MS, }, ); if (result.status !== 0) { throw new Error(`Released Runtime Host failed: ${result.stderr || result.stdout}`); } const ready = findJsonLine(result.stdout, (value) => value.event === 'runtime_host_ready'); if (!ready?.rootId || !ready.hostEpoch) { throw new Error(`The released Runtime Host did not publish Ready: ${result.stderr}`); } return { rootId: ready.rootId, hostEpoch: ready.hostEpoch }; } function parseLastJsonLine(output) { const lines = output.trim().split(/\r?\n/u); for (let index = lines.length - 1; index >= 0; index -= 1) { try { return JSON.parse(lines[index]); } catch { // Earlier lines may contain runtime diagnostics. } } throw new Error('Released fixture produced no JSON evidence'); } function findJsonLine(output, predicate) { for (const line of output.split(/\r?\n/u)) { if (!line.trim().startsWith('{')) continue; try { const value = JSON.parse(line); if (predicate(value)) return value; } catch { // Non-JSON diagnostics remain outside the evidence channel. } } return undefined; } function assertFacts(value) { if ( value.kind !== 'facts' || !value.rootId || !value.session?.id || !value.session?.message?.id || !value.scheduledTask?.id ) { throw new Error('Released fixture evidence is incomplete'); } } function assertSameFacts(expected, actual, stage) { assertFacts(actual); if (JSON.stringify(actual) !== JSON.stringify(expected)) { throw new Error(`Durable facts changed during ${stage}`); } } function restoreGolden(rootPath, goldenPath) { for (const entry of readdirSync(rootPath)) { rmSync(join(rootPath, entry), { recursive: true, force: true }); } for (const entry of readdirSync(goldenPath)) { cpSync(join(goldenPath, entry), join(rootPath, entry), { recursive: true, force: true, }); } } function artifactEvidence(artifact, sha256) { return { version: artifact.version, compatibilityEpoch: artifact.compatibilityEpoch, sha256, }; } async function allocateLoopbackPort() { const server = createServer(); await new Promise((resolvePromise, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolvePromise); }); const address = server.address(); await new Promise((resolvePromise, reject) => server.close((error) => (error ? reject(error) : resolvePromise())), ); if (!address || typeof address === 'string') throw new Error('Unable to allocate a port'); return address.port; } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { try { if (process.argv[2] === '--inner') { const innerInputPath = process.argv[3]; if (!innerInputPath || !isAbsolute(innerInputPath) || process.argv.length !== 4) { throw new Error('Qualification sandbox requires one absolute input path'); } const report = await qualifyInstalledArtifacts( JSON.parse(readFileSync(innerInputPath, 'utf8')), ); process.stdout.write(`${JSON.stringify(report)}\n`); } else { const report = await qualifyReleasedCliStateRoot( parseQualificationArgs(process.argv.slice(2)), ); process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); } } catch (error) { process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); process.exitCode = 1; } }