scripts/fetch-repokit.mjs
Rama master · réplica de lectura del repositorio original.
#!/usr/bin/env node
// Fetch the banner generator from a commit-pinned dwgx/DWGX and verify every byte
// against repo-kit.lock.json before writing anything.
//
// WHY: banner.yml runs this Python while holding a token that can push to this
// repository. A mutable branch upstream is remote code execution here. The lock makes
// the fetch content-addressed: the commit freezes the URL, and the per-file sha256
// catches a compromised CDN or a rewritten upstream.
//
// CONTRACT: exit 0 only after all four files match the lock. A malformed lock, a
// missing file, a non-200, or any hash mismatch exits 1 and leaves --dest without a
// build.py (or any of the other three generator files) — verification happens
// entirely in memory before the first write. On success --dest is replaced wholesale
// from a fresh staging directory, so no extra module (e.g. tomllib.py) or package
// (build/) can survive and shadow what Python imports.
//
// Usage: node scripts/fetch-repokit.mjs --dest <dir> [--lock <file>] [--base <url>]
// --base overrides https://raw.githubusercontent.com/dwgx/DWGX/<commit>/repo-kit;
// tests pass a file:// URL so they never touch the network.
import { createHash } from 'node:crypto';
import { lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { dirname, join, parse, resolve, sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const REQUIRED_FILES = ['build.py', 'styles.py', 'styles_ink.py', 'ink.py'];
const DEFAULT_LOCK = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'repo-kit.lock.json');
const COMMIT_RE = /^[0-9a-f]{40}$/;
const SHA256_RE = /^[0-9a-f]{64}$/;
function fail(message) {
throw new Error(message);
}
function parseArgs(argv) {
const options = { dest: null, lock: DEFAULT_LOCK, base: null };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--dest' || arg === '--lock' || arg === '--base') {
const value = argv[i + 1];
if (!value) fail(`${arg} needs a value`);
options[arg.slice(2)] = value;
i += 1;
} else if (arg === '--help' || arg === '-h') {
console.log('usage: node scripts/fetch-repokit.mjs --dest <dir> [--lock <file>] [--base <url>]');
process.exit(0);
} else {
fail(`unknown argument: ${arg}`);
}
}
if (!options.dest) fail('--dest is required');
return options;
}
function validateLock(raw) {
let lock;
try {
lock = JSON.parse(raw);
} catch (error) {
fail(`lock is not valid JSON: ${error.message}`);
}
if (!lock || typeof lock !== 'object' || Array.isArray(lock)) fail('lock must be a JSON object');
if (typeof lock.commit !== 'string' || !COMMIT_RE.test(lock.commit)) {
fail(`lock commit must be a 40-hex commit SHA, got ${JSON.stringify(lock.commit)}`);
}
const files = lock.files;
if (!files || typeof files !== 'object' || Array.isArray(files)) fail('lock files must be an object');
const names = Object.keys(files).sort();
const expected = [...REQUIRED_FILES].sort();
if (names.length !== expected.length || names.some((name, index) => name !== expected[index])) {
fail(`lock must name exactly ${REQUIRED_FILES.join(', ')}; got ${names.join(', ') || '(none)'}`);
}
for (const name of REQUIRED_FILES) {
if (typeof files[name] !== 'string' || !SHA256_RE.test(files[name])) {
fail(`lock hash for ${name} must be a 64-hex sha256`);
}
}
return lock;
}
function sourceUrl(base, name) {
const root = /^[a-z][a-z0-9+.-]*:\/\//i.test(base)
? base
: pathToFileURL(resolve(base)).href;
return new URL(name, root.endsWith('/') ? root : `${root}/`);
}
async function readSource(url) {
if (url.protocol === 'file:') {
try {
return await readFile(fileURLToPath(url));
} catch (error) {
throw new Error(`cannot read ${url.href}: ${error.message}`);
}
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error(`unsupported base protocol: ${url.protocol}`);
}
const response = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(30_000) });
if (response.status !== 200) throw new Error(`${url.href} -> HTTP ${response.status}`);
return Buffer.from(await response.arrayBuffer());
}
function assertSafeDest(dest) {
if (dest === parse(dest).root) fail('--dest must not be a filesystem root');
const cwd = resolve('.');
if (dest === cwd || cwd.startsWith(dest.endsWith(sep) ? dest : `${dest}${sep}`)) {
fail('--dest must not be the current working directory or one of its parents');
}
}
// Any failure after --dest is known removes the destination itself. Keeping the
// directory and deleting only the four names would let a pre-existing build/ package
// or build.pyc shadow the missing build.py; the destination is dedicated output, so
// fail closed by removing it entirely.
async function discardDestination(dest) {
await rm(dest, { recursive: true, force: true });
}
// Build the complete set in a fresh directory next to --dest, then swap it into place.
// The destination therefore contains exactly the four verified files: a leftover
// tomllib.py, build/ package or any other import-shadowing path cannot survive.
async function replaceDestination(dest, files) {
const parent = dirname(dest);
await mkdir(parent, { recursive: true });
const staging = await mkdtemp(join(parent, '.repokit-stage-'));
try {
for (const name of REQUIRED_FILES) {
await writeFile(join(staging, name), files.get(name), { mode: 0o644 });
}
let backup = null;
let exists = false;
try {
await lstat(dest);
exists = true;
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
if (exists) {
backup = join(parent, `.repokit-old-${process.pid}-${Date.now()}`);
await rename(dest, backup);
}
try {
await rename(staging, dest);
} catch (error) {
if (backup) await rename(backup, dest).catch(() => {});
throw error;
}
if (backup) await rm(backup, { recursive: true, force: true }).catch(() => {});
} finally {
await rm(staging, { recursive: true, force: true }).catch(() => {});
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const dest = resolve(options.dest);
await assertSafeDest(dest);
try {
let raw;
try {
raw = await readFile(options.lock, 'utf8');
} catch (error) {
fail(`cannot read lock ${options.lock}: ${error.message}`);
}
const lock = validateLock(raw);
const base = options.base
|| `https://raw.githubusercontent.com/dwgx/DWGX/${lock.commit}/repo-kit`;
const verified = new Map();
for (const name of REQUIRED_FILES) {
let bytes;
try {
bytes = await readSource(sourceUrl(base, name));
} catch (error) {
fail(`${name}: ${error.message}`);
}
const actual = createHash('sha256').update(bytes).digest('hex');
if (actual !== lock.files[name]) {
fail(`${name}: sha256 mismatch (lock ${lock.files[name]}, fetched ${actual})`);
}
verified.set(name, bytes);
}
// Every byte is verified before this line, so a mismatch can never leave build.py
// (or a partial set) in the directory Python imports from.
await replaceDestination(dest, verified);
console.log(`fetch-repokit: verified ${REQUIRED_FILES.length} files from dwgx/DWGX@${lock.commit}`);
} catch (error) {
await discardDestination(dest).catch(() => {});
throw error;
}
}
try {
await main();
} catch (error) {
console.error(`fetch-repokit: ${error?.message || String(error)}`);
process.exitCode = 1;
}