test/banner-workflow-pins.test.js
Rama master · réplica de lectura del repositorio original.
// D9 §6.4 — the banner trust lock.
//
// `banner.yml` executes Python fetched from another repository while holding a token
// that can push to this one. Three files make that trust boundary mechanical:
//
// repo-kit.lock.json pins the upstream commit and the exact bytes of every file
// scripts/fetch-repokit.mjs refuses anything whose sha256 does not match the lock
// banner.yml runs the verifier before `python` imports build.py
//
// These assertions fail if any leg is removed: a floating commit, a skipped hash, a
// partially-written destination, or an unpinned action would each turn this file red.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { spawn, spawnSync } from 'node:child_process';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const LOCK_PATH = join(ROOT, 'repo-kit.lock.json');
const FETCH_SCRIPT = join(ROOT, 'scripts', 'fetch-repokit.mjs');
const WORKFLOW_DIR = join(ROOT, '.github', 'workflows');
const REQUIRED_FILES = ['build.py', 'styles.py', 'styles_ink.py', 'ink.py'];
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
function runFetch(args) {
assert.ok(existsSync(FETCH_SCRIPT), 'scripts/fetch-repokit.mjs must exist');
const env = { ...process.env };
delete env.NODE_OPTIONS;
delete env.NODE_TEST_CONTEXT;
return spawnSync(process.execPath, [FETCH_SCRIPT, ...args], {
cwd: ROOT, env, encoding: 'utf8', timeout: 30_000,
});
}
function readLock(path = LOCK_PATH) {
return JSON.parse(readFileSync(path, 'utf8'));
}
// A one-request HTTP server in its own process: spawnSync below blocks this event loop,
// so an in-process server could never answer.
function startHttpServer(status) {
const source = [
"const http = require('node:http');",
`const server = http.createServer((req, res) => { res.statusCode = ${status}; res.end('partial'); });`,
"server.listen(0, '127.0.0.1', () => console.log(server.address().port));",
].join('');
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, ['-e', source], { stdio: ['ignore', 'pipe', 'inherit'] });
let output = '';
const timer = setTimeout(() => reject(new Error('HTTP fixture did not start')), 10_000);
child.stdout.on('data', (chunk) => {
output += chunk;
const match = /^(\d+)\s*$/m.exec(output);
if (!match) return;
clearTimeout(timer);
resolve({ child, port: Number(match[1]) });
});
child.on('error', reject);
});
}
// A hermetic upstream: four files on disk, a lock that describes exactly those bytes,
// and a destination that starts empty. `--base file://…` keeps the test off the network.
function makeFixture() {
const parent = mkdtempSync(join(tmpdir(), 'wa-repokit-'));
const upstream = join(parent, 'upstream');
const dest = join(parent, 'dest');
mkdirSync(upstream);
const bytes = {
'build.py': Buffer.from('print("banner")\n'),
'styles.py': Buffer.from('STYLE = "ink"\n'),
'styles_ink.py': Buffer.from('INK = True\n'),
'ink.py': Buffer.from('THEME = "ink"\n'),
};
for (const [name, body] of Object.entries(bytes)) writeFileSync(join(upstream, name), body);
const lock = {
commit: 'a'.repeat(40),
files: Object.fromEntries(REQUIRED_FILES.map((name) => [name, sha256(bytes[name])])),
};
const writeLock = (value) => {
const path = join(parent, 'repo-kit.lock.json');
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
return path;
};
return {
parent, upstream, dest, bytes, lock, writeLock,
base: pathToFileURL(upstream).href,
cleanup: () => rmSync(parent, { recursive: true, force: true }),
};
}
describe('repo-kit.lock.json', () => {
it('pins one 40-hex commit and a 64-hex sha256 for exactly the four generator files', () => {
const lock = readLock();
assert.equal(typeof lock.commit, 'string');
assert.match(lock.commit, /^[0-9a-f]{40}$/,
'the lock must pin a full 40-hex commit, not a branch or abbreviated SHA');
assert.ok(lock.files && typeof lock.files === 'object' && !Array.isArray(lock.files),
'the lock must carry a files map');
assert.deepEqual(Object.keys(lock.files).sort(), [...REQUIRED_FILES].sort(),
'the lock must name exactly build.py, styles.py, styles_ink.py and ink.py');
for (const name of REQUIRED_FILES) {
assert.match(lock.files[name], /^[0-9a-f]{64}$/,
`${name} must be pinned by a lowercase 64-hex sha256`);
}
});
});
describe('banner workflow', () => {
const workflowFiles = readdirSync(WORKFLOW_DIR)
.filter((name) => name.endsWith('.yml') || name.endsWith('.yaml'))
.sort();
it('pins every action in every workflow to a 40-hex commit', () => {
assert.deepEqual(workflowFiles,
['banner.yml', 'baseline-audit.yml', 'ci.yml', 'release.yml', 'star-history.yml'],
'the workflow inventory changed; review the new workflow before adding it here');
const unpinned = [];
for (const file of workflowFiles) {
const lines = readFileSync(join(WORKFLOW_DIR, file), 'utf8').split(/\r?\n/);
for (const [index, line] of lines.entries()) {
const match = /^\s*(?:-\s*)?uses\s*:\s*['"]?(\S+?)['"]?\s*(?:#.*)?$/.exec(line);
if (!match) continue;
const ref = match[1];
// A local action (`./path`) has no remote ref to pin; everything else must be @<40hex>.
if (ref.startsWith('./')) continue;
if (!/@[0-9a-f]{40}$/.test(ref)) unpinned.push(`${file}:${index + 1} ${ref}`);
}
}
assert.deepEqual(unpinned, [], `unpinned uses:\n ${unpinned.join('\n ')}`);
});
it('fetches the generator through the verifier before python imports it', () => {
const workflow = readFileSync(join(WORKFLOW_DIR, 'banner.yml'), 'utf8');
assert.doesNotMatch(workflow, /raw\.githubusercontent\.com/,
'banner.yml must not fetch mutable upstream bytes directly');
const stepAt = (name) => workflow.indexOf(`- name: ${name}`);
const fetchStep = stepAt('fetch and verify generator');
const renderStep = stepAt('render');
const commitStep = stepAt('commit');
assert.ok(fetchStep !== -1 && renderStep !== -1 && commitStep !== -1,
'banner.yml must keep the fetch, render and commit steps');
assert.ok(fetchStep < renderStep && renderStep < commitStep,
'the verifier step must precede render and commit');
const fetchBlock = workflow.slice(fetchStep, renderStep);
const fetchCommand = /^\s*node scripts\/fetch-repokit\.mjs --dest \/tmp\/repokit\s*$/m.exec(fetchBlock);
assert.ok(fetchCommand, 'the fetch step must run node scripts/fetch-repokit.mjs --dest /tmp/repokit');
const importProbe = /^\s*python -c "import sys; sys\.path\.insert\(0,'\/tmp\/repokit'\); import build/m.exec(fetchBlock);
assert.ok(importProbe, 'the python import probe must exist in the fetch step');
assert.ok(fetchCommand.index < importProbe.index,
'the verifier command must run before python imports build.py');
// The bot commit is the only reason this workflow needs a write token.
assert.match(workflow, /^permissions:\s*\n\s*contents:\s*write/m,
'banner.yml needs contents: write for its own bot commit');
});
});
describe('scripts/fetch-repokit.mjs', () => {
it('writes all four files when every byte matches the lock', () => {
const f = makeFixture();
try {
const result = runFetch(['--lock', f.writeLock(f.lock), '--dest', f.dest, '--base', f.base]);
assert.equal(result.status, 0, `fetch must succeed:\n${result.stdout}${result.stderr}`);
assert.deepEqual(readdirSync(f.dest).sort(), [...REQUIRED_FILES].sort(),
'the destination must contain exactly the four verified files');
for (const name of REQUIRED_FILES) {
assert.deepEqual(readFileSync(join(f.dest, name)), f.bytes[name],
`${name} must be written byte-for-byte`);
}
} finally { f.cleanup(); }
});
it('exits 1 on a non-200 response', async () => {
const { child, port } = await startHttpServer(206);
const f = makeFixture();
try {
const result = runFetch(['--lock', f.writeLock(f.lock), '--dest', f.dest, '--base', `http://127.0.0.1:${port}`]);
assert.equal(result.status, 1, 'a 206 must not be accepted as a complete file');
assert.match(result.stderr, /HTTP 206/, 'the refusal must name the status');
assert.equal(existsSync(f.dest), false, 'nothing may be written for a non-200');
} finally {
child.kill();
f.cleanup();
}
});
it('exits 1 and writes no build.py when a later file is tampered', () => {
const f = makeFixture();
try {
// Tamper the LAST file: a verifier that wrote each file as it went would already
// have left build.py behind, so this catches partial writes, not just a bad first byte.
writeFileSync(join(f.upstream, 'ink.py'), Buffer.concat([f.bytes['ink.py'], Buffer.from('# tampered\n')]));
const result = runFetch(['--lock', f.writeLock(f.lock), '--dest', f.dest, '--base', f.base]);
assert.equal(result.status, 1, `a hash mismatch must fail:\n${result.stdout}${result.stderr}`);
assert.equal(existsSync(join(f.dest, 'build.py')), false,
'a mismatch must never leave build.py in the import directory');
assert.equal(existsSync(f.dest), false, 'nothing may be written before every file verifies');
assert.match(result.stderr, /sha256|mismatch|hash/i, 'the failure must name the integrity check');
} finally { f.cleanup(); }
});
it('replaces the destination so an extra importable file cannot survive', () => {
const f = makeFixture();
try {
// sys.path[0] is the whole destination: a leftover tomllib.py would shadow the
// stdlib module that build.py imports. Success must leave exactly the four files.
mkdirSync(f.dest);
writeFileSync(join(f.dest, 'build.py'), Buffer.from('stale\n'));
writeFileSync(join(f.dest, 'tomllib.py'), Buffer.from('raise SystemExit("evil")\n'));
mkdirSync(join(f.dest, 'build'));
writeFileSync(join(f.dest, 'build', '__init__.py'), Buffer.from('raise SystemExit("evil")\n'));
const result = runFetch(['--lock', f.writeLock(f.lock), '--dest', f.dest, '--base', f.base]);
assert.equal(result.status, 0, `fetch must succeed:\n${result.stdout}${result.stderr}`);
assert.deepEqual(readdirSync(f.dest).sort(), [...REQUIRED_FILES].sort(),
'the destination must contain exactly the four verified files');
assert.equal(existsSync(join(f.dest, 'tomllib.py')), false,
'a leftover module must not shadow a stdlib import');
assert.equal(existsSync(join(f.dest, 'build')), false,
'a leftover package must not shadow build.py');
} finally { f.cleanup(); }
});
it('removes the whole destination when verification fails', () => {
const f = makeFixture();
try {
// A reused destination must not keep any importable entry after a refusal: a
// build/ package or build.pyc shadows build.py even if the four files are gone.
mkdirSync(f.dest);
for (const name of REQUIRED_FILES) {
writeFileSync(join(f.dest, name), Buffer.from(`stale unverified ${name}\n`));
}
writeFileSync(join(f.dest, 'tomllib.py'), Buffer.from('raise SystemExit("evil")\n'));
writeFileSync(join(f.dest, 'build.pyc'), Buffer.from('evil bytecode'));
mkdirSync(join(f.dest, 'build'));
writeFileSync(join(f.dest, 'build', '__init__.py'), Buffer.from('raise SystemExit("evil")\n'));
writeFileSync(join(f.upstream, 'ink.py'), Buffer.concat([f.bytes['ink.py'], Buffer.from('# tampered\n')]));
const result = runFetch(['--lock', f.writeLock(f.lock), '--dest', f.dest, '--base', f.base]);
assert.equal(result.status, 1, 'a hash mismatch must fail');
assert.equal(existsSync(f.dest), false,
'a refused fetch must remove the destination so no importable entry survives');
} finally { f.cleanup(); }
});
it('exits 1 before fetching when the lock commit is not 40 hex', () => {
const f = makeFixture();
try {
const lock = { ...f.lock, commit: 'main' };
const result = runFetch(['--lock', f.writeLock(lock), '--dest', f.dest, '--base', f.base]);
assert.equal(result.status, 1, 'a branch name must be refused');
assert.match(result.stderr, /lock commit must be a 40-hex/i, 'the refusal must name the commit pin');
assert.doesNotMatch(result.stderr, /cannot read|HTTP/i, 'the commit must be checked before any fetch');
assert.equal(existsSync(join(f.dest, 'build.py')), false);
} finally { f.cleanup(); }
});
it('exits 1 on malformed lock JSON before fetching', () => {
const f = makeFixture();
try {
const lockPath = join(f.parent, 'bad-lock.json');
writeFileSync(lockPath, '{ "commit": ');
const result = runFetch(['--lock', lockPath, '--dest', f.dest, '--base', f.base]);
assert.equal(result.status, 1, 'malformed JSON must be refused');
assert.match(result.stderr, /JSON/i, 'the refusal must name the lock parse');
assert.equal(existsSync(f.dest), false);
} finally { f.cleanup(); }
});
it('exits 1 and writes nothing when a locked file is missing upstream', () => {
const f = makeFixture();
try {
rmSync(join(f.upstream, 'ink.py'));
const result = runFetch(['--lock', f.writeLock(f.lock), '--dest', f.dest, '--base', f.base]);
assert.equal(result.status, 1, 'a missing file must fail');
assert.match(result.stderr, /ink\.py.*cannot read/i, 'the refusal must name the missing file');
assert.equal(existsSync(join(f.dest, 'build.py')), false);
} finally { f.cleanup(); }
});
it('exits 1 when the lock names an extra file', () => {
const f = makeFixture();
try {
const lock = { ...f.lock, files: { ...f.lock.files, 'extra.py': 'b'.repeat(64) } };
const result = runFetch(['--lock', f.writeLock(lock), '--dest', f.dest, '--base', f.base]);
assert.equal(result.status, 1, 'an extra locked file must be refused');
assert.match(result.stderr, /lock must name exactly/i, 'the refusal must name the file set');
assert.equal(existsSync(join(f.dest, 'build.py')), false);
} finally { f.cleanup(); }
});
});