From ddf637130478e69a62a02044fe2bd9cf602329b4 Mon Sep 17 00:00:00 2001 From: Yurii M Date: Wed, 22 Jul 2026 19:12:50 +0300 Subject: [PATCH] Extract the release archive without shelling out to tar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first automatic release failed on the host: "tar: This does not look like a tar archive". Shelling out to `tar -xf` for a zip only ever worked because the machine it was written on was Windows, where tar is bsdtar and reads zip. The runner is Linux, where tar is GNU tar and does not. Nothing about calling the system archiver was portable, so it is gone. Zip reading now happens in Node with zlib, which behaves the same everywhere and lets unsafe entries be rejected before anything is written rather than after. Deliberately minimal — store and deflate, no zip64, no encryption. The archives come from our own release workflow, and anything else should be refused loudly rather than half-understood. Verified against the archive from the failed run: all six entries extract byte-identically to an independent implementation (Windows Expand-Archive), including the 21 MB wasm. A crafted archive containing "../evil.txt" is rejected with nothing written outside the target, and both that and a checksum mismatch exit non-zero so the workflow fails. Co-Authored-By: Claude Opus 4.8 --- scripts/sync-whisper-transcriber.mjs | 96 +++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 2 deletions(-) diff --git a/scripts/sync-whisper-transcriber.mjs b/scripts/sync-whisper-transcriber.mjs index 5f7d347..c129cc6 100644 --- a/scripts/sync-whisper-transcriber.mjs +++ b/scripts/sync-whisper-transcriber.mjs @@ -23,6 +23,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; +import zlib from 'node:zlib'; const SITE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const DEFAULT_SOURCE_DIR = path.resolve( @@ -176,14 +177,105 @@ function extractArchive(archive) { console.log(`[whisper-transcriber] Archive checksum verified: ${actual}`); scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'whisper-transcriber-')); - // bsdtar reads zip archives and is present on Windows, macOS and the runners. - run('tar', ['-xf', path.resolve(archive), '-C', scratchDir]); + unzipTo(path.resolve(archive), scratchDir); // Accept both a flat archive and one that wraps the files in dist/. const nested = path.join(scratchDir, 'dist'); return fs.existsSync(path.join(nested, 'index.html')) ? nested : scratchDir; } +/** + * Extracts a zip using Node alone. + * + * This used to shell out to `tar -xf`, which works on Windows and macOS — + * where `tar` is bsdtar and reads zip — and fails on Linux, where it is GNU tar + * and does not. Nothing about "call the system tar" was ever portable; it just + * happened to match the machine it was written on. Doing it here removes the + * dependency on which archiver a host has installed, and lets entry paths be + * rejected before anything is written rather than after. + * + * Deliberately minimal: store and deflate, no zip64, no encryption. The + * archives come from our own release workflow, and anything else should be + * refused loudly rather than half-understood. + */ +function unzipTo(archivePath, targetDir) { + const buffer = fs.readFileSync(archivePath); + + const EOCD_SIGNATURE = 0x06054b50; + const CENTRAL_SIGNATURE = 0x02014b50; + + // The end-of-central-directory record sits at the tail, after an optional + // comment, so it has to be found by scanning backwards. + let eocd = -1; + for (let i = buffer.length - 22; i >= 0 && i >= buffer.length - 66_000; i -= 1) { + if (buffer.readUInt32LE(i) === EOCD_SIGNATURE) { + eocd = i; + break; + } + } + if (eocd === -1) fail('Archive is not a zip file (no end-of-central-directory record).'); + + const entryCount = buffer.readUInt16LE(eocd + 10); + const centralOffset = buffer.readUInt32LE(eocd + 16); + if (entryCount === 0xffff || centralOffset === 0xffffffff) { + fail('Zip64 archives are not supported.'); + } + + let cursor = centralOffset; + let written = 0; + + for (let entry = 0; entry < entryCount; entry += 1) { + if (buffer.readUInt32LE(cursor) !== CENTRAL_SIGNATURE) { + fail(`Malformed zip: central directory entry ${entry} has a bad signature.`); + } + + const method = buffer.readUInt16LE(cursor + 10); + const compressedSize = buffer.readUInt32LE(cursor + 20); + const nameLength = buffer.readUInt16LE(cursor + 28); + const extraLength = buffer.readUInt16LE(cursor + 30); + const commentLength = buffer.readUInt16LE(cursor + 32); + const localOffset = buffer.readUInt32LE(cursor + 42); + const name = buffer.toString('utf8', cursor + 46, cursor + 46 + nameLength); + + cursor += 46 + nameLength + extraLength + commentLength; + + // Directory entries carry no data. + if (name.endsWith('/')) continue; + + // Reject before writing, not after: an entry that escapes the target must + // never touch the filesystem in the first place. + const normalized = name.replace(/\\/g, '/'); + if (path.isAbsolute(normalized) || normalized.split('/').includes('..')) { + fail(`Refusing to extract unsafe path from archive: ${name}`); + } + + // The local header repeats the name and extra fields, and its extra field + // length can differ from the central one — so the payload offset has to be + // read from the local header rather than assumed. + const localNameLength = buffer.readUInt16LE(localOffset + 26); + const localExtraLength = buffer.readUInt16LE(localOffset + 28); + const dataStart = localOffset + 30 + localNameLength + localExtraLength; + const raw = buffer.subarray(dataStart, dataStart + compressedSize); + + let contents; + if (method === 0) { + contents = raw; + } else if (method === 8) { + contents = zlib.inflateRawSync(raw); + } else { + fail(`Unsupported zip compression method ${method} for ${name}.`); + } + + const destination = path.join(targetDir, normalized); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.writeFileSync(destination, contents); + written += 1; + } + + if (written === 0) fail('Archive contained no files.'); + console.log(`[whisper-transcriber] Extracted ${written} files from the archive`); +} + /** Produces the directory whose contents should be published. */ function resolveDistDir() { if (archivePath) {