diff --git a/README.md b/README.md index 6916e6a..d8ef6e8 100644 --- a/README.md +++ b/README.md @@ -193,3 +193,8 @@ This optional test uses `web-ext` and a disposable headless profile, never your signed-in profile. It exercises the real compatibility layer against local HTTP fixtures and verifies lifecycle delivery and foreground-tab preservation. It does not exercise the signed-in Codex native transport. + +For an unsigned review build on macOS, Linux, or Windows, commit the working tree +and run `pnpm package:portable`. It uses Node and Git, writes ZIP/XPI and a review-source +archive under `dist/`, and preserves the release checksum filenames. The existing +PowerShell packaging command and release workflow are unchanged. diff --git a/package.json b/package.json index 6326b76..c0ba6fe 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,10 @@ "scripts": { "check": "node scripts/check-version.mjs && node scripts/verify-extension.mjs", "version:set": "node scripts/set-version.mjs", - "test": "npm run check && node tests/test-amo-auth.mjs && node tests/test-companion-required.mjs && node tests/test-sidepanel-focus-compat.mjs && node tests/test-sidepanel-host-access.mjs && node tests/test-sidepanel-bootstrap.mjs && node tests/test-sidepanel-companion-setup.mjs && node tests/test-chatgpt-feature-parity.mjs && node tests/test-firefox-compat.mjs && node tests/test-editing-assets.mjs && node tests/test-native-host.mjs", + "test": "npm run check && node tests/test-amo-auth.mjs && node tests/test-companion-required.mjs && node tests/test-sidepanel-focus-compat.mjs && node tests/test-sidepanel-host-access.mjs && node tests/test-sidepanel-bootstrap.mjs && node tests/test-sidepanel-companion-setup.mjs && node tests/test-chatgpt-feature-parity.mjs && node tests/test-firefox-compat.mjs && node tests/test-editing-assets.mjs && node tests/test-native-host.mjs && node tests/test-portable-packaging.mjs", "package": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/package-extension.ps1", - "test:live": "node tests/test-firefox-lifecycle-live.mjs" + "test:package": "node tests/test-portable-packaging.mjs", + "test:live": "node tests/test-firefox-lifecycle-live.mjs", + "package:portable": "node scripts/package-extension.mjs" } } diff --git a/scripts/package-extension.mjs b/scripts/package-extension.mjs new file mode 100644 index 0000000..7517b81 --- /dev/null +++ b/scripts/package-extension.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFileSync } from "node:child_process"; +import { deflateRawSync } from "node:zlib"; +import { createHash } from "node:crypto"; + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +process.chdir(root); +for (const script of ["check-version.mjs", "verify-extension.mjs"]) { + execFileSync(process.execPath, [path.join("scripts", script)], { stdio: "inherit" }); +} +if (execFileSync("git", ["status", "--porcelain", "--untracked-files=normal"], { encoding: "utf8" }).trim()) { + throw new Error("Commit the source before packaging so the source archive matches the extension."); +} +// Freeze one tree for both artifacts. Checkout filters (such as autocrlf) +// and ignored local files must never affect the reviewed package bytes. +const commit = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(); +const entries = execFileSync("git", ["ls-tree", "-rz", "--full-tree", commit], { + encoding: "utf8", maxBuffer: 16 * 1024 * 1024, +}).split("\0").filter(Boolean).map(entry => { + const match = /^(\d+) (\w+) ([a-f\d]+)\t([\s\S]+)$/u.exec(entry); + if (!match || match[2] !== "blob" || !["100644", "100755"].includes(match[1])) { + throw new Error(`Refusing to package non-regular committed entry ${entry}`); + } + return { name: match[4], object: match[3], mode: parseInt(match[1], 8) }; +}).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); +const blobs = execFileSync("git", ["cat-file", "--batch"], { + input: entries.map(entry => entry.object).join("\n") + "\n", + maxBuffer: 256 * 1024 * 1024, +}); +let cursor = 0; +const sourceFiles = entries.map(entry => { + const end = blobs.indexOf(10, cursor); + const header = blobs.subarray(cursor, end).toString("ascii").split(" "); + const size = Number(header[2]); + if (end < 0 || header[0] !== entry.object || header[1] !== "blob" || !Number.isSafeInteger(size)) { + throw new Error(`Invalid committed blob for ${entry.name}`); + } + cursor = end + 1; + const data = blobs.subarray(cursor, cursor + size); + cursor += size + 1; + return { name: entry.name, data, mode: entry.mode }; +}); +const { version } = JSON.parse(sourceFiles.find(file => file.name === "version.json").data); +const stem = `codex-computer-use-firefox-zen-${version}`; +const dist = path.join(root, "dist"); +fs.mkdirSync(dist, { recursive: true }); + +const crcTable = Array.from({ length: 256 }, (_, value) => { + for (let bit = 0; bit < 8; bit++) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + return value >>> 0; +}); +function crc32(data) { + let crc = 0xffffffff; + for (const byte of data) crc = crcTable[(crc ^ byte) & 255] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +const files = sourceFiles.filter(file => file.name.startsWith("extension/")) + .map(file => ({ name: file.name.slice("extension/".length), data: file.data, mode: file.mode })); + +function zip(files) { + if (files.length >= 65535) throw new Error("ZIP64 is not supported by this packager."); + const parts = []; + const central = []; + let offset = 0; + for (const { name, data, mode } of files) { + const filename = Buffer.from(name); + const compressed = deflateRawSync(data, { level: 9 }); + const crc = crc32(data); + if (filename.length > 65535 || data.length > 0xffffffff || offset > 0xffffffff) { + throw new Error(`ZIP64 is required for ${name}.`); + } + // ZIP-standard paths, UTF-8 names, and a fixed 1980-01-01 timestamp. + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x800, 6); + local.writeUInt16LE(8, 8); + local.writeUInt16LE(0x21, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(compressed.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(filename.length, 26); + const entry = Buffer.alloc(46); + entry.writeUInt32LE(0x02014b50); + entry.writeUInt16LE((3 << 8) | 20, 4); // Unix creator: preserve committed file modes. + entry.writeUInt16LE(20, 6); + entry.writeUInt16LE(0x800, 8); + entry.writeUInt16LE(8, 10); + entry.writeUInt16LE(0x21, 14); + entry.writeUInt32LE(crc, 16); + entry.writeUInt32LE(compressed.length, 20); + entry.writeUInt32LE(data.length, 24); + entry.writeUInt16LE(filename.length, 28); + entry.writeUInt32LE((mode << 16) >>> 0, 38); + entry.writeUInt32LE(offset, 42); + parts.push(local, filename, compressed); + central.push(entry, filename); + offset += local.length + filename.length + compressed.length; + } + const centralData = Buffer.concat(central); + if (offset + centralData.length > 0xffffffff) throw new Error("ZIP64 is required for this archive."); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(centralData.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...parts, centralData, end]); +} +const archive = zip(files); +for (const suffix of ["zip", "xpi"]) fs.writeFileSync(path.join(dist, `${stem}.${suffix}`), archive); +fs.writeFileSync(path.join(dist, `${stem}-source.zip`), zip(sourceFiles)); + +const artifacts = [`${stem}.zip`, `${stem}.xpi`, `${stem}-source.zip`]; +const checksums = artifacts.map((name) => { + const checksum = `${createHash("sha256").update(fs.readFileSync(path.join(dist, name))).digest("hex")} ${name}\n`; + // Preserve the existing release workflow's per-artifact checksum names. + fs.writeFileSync(path.join(dist, name.endsWith(".zip") ? name.slice(0, -4) + ".sha256" : name + ".sha256"), checksum); + return checksum; +}); +fs.writeFileSync(path.join(dist, "SHA256SUMS"), checksums.join("")); +console.log(JSON.stringify({ + version, + commit, + files: files.length, + artifacts: artifacts.map((name) => ({ path: path.join(dist, name), bytes: fs.statSync(path.join(dist, name)).size })), +}, null, 2)); diff --git a/tests/test-portable-packaging.mjs b/tests/test-portable-packaging.mjs new file mode 100644 index 0000000..0e92c75 --- /dev/null +++ b/tests/test-portable-packaging.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { inflateRawSync } from "node:zlib"; +import { fileURLToPath } from "node:url"; + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const temp = fs.mkdtempSync(path.resolve(os.tmpdir(), "portable-packaging-")); +let checkout = path.join(temp, "checkout"); +const git = (...args) => execFileSync("git", args, { cwd: checkout, encoding: "utf8" }); +function zipEntries(buffer) { + const entries = new Map(); + let offset = 0; + while (buffer.readUInt32LE(offset) === 0x04034b50) { + const compressedSize = buffer.readUInt32LE(offset + 18); + const nameSize = buffer.readUInt16LE(offset + 26); + const extraSize = buffer.readUInt16LE(offset + 28); + const name = buffer.subarray(offset + 30, offset + 30 + nameSize).toString(); + const start = offset + 30 + nameSize + extraSize; + entries.set(name, inflateRawSync(buffer.subarray(start, start + compressedSize))); + offset = start + compressedSize; + } + return entries; +} +try { + execFileSync("git", ["clone", "--quiet", "--no-hardlinks", root, checkout]); + // Test the script being edited, even before its parent commit is published. + fs.copyFileSync(path.join(root, "scripts/package-extension.mjs"), path.join(checkout, "scripts/package-extension.mjs")); + git("add", "scripts/package-extension.mjs"); + // A disposable fixture must not depend on the contributor's signing key. + git("-c", "user.name=Packaging regression", "-c", "user.email=packaging@localhost", + "-c", "commit.gpgsign=false", "commit", "--quiet", "--allow-empty", "-m", "Canonical packaging fixture"); + const { version } = JSON.parse(fs.readFileSync(path.join(checkout, "version.json"))); + const stem = `codex-computer-use-firefox-zen-${version}`; + const build = () => { + execFileSync(process.execPath, ["scripts/package-extension.mjs"], { cwd: checkout, stdio: "pipe" }); + return ["zip", "xpi", "source.zip"].map(suffix => fs.readFileSync( + path.join(checkout, "dist", suffix === "source.zip" ? `${stem}-source.zip` : `${stem}.${suffix}`), + )); + }; + const baseline = build(); + assert.deepEqual(baseline[0], baseline[1], "ZIP and XPI must carry identical bytes."); + const packaged = zipEntries(baseline[0]); + const source = zipEntries(baseline[2]); + assert.equal(packaged.size, [...source.keys()].filter(name => name.startsWith("extension/")).length); + for (const [name, data] of packaged) { + assert.deepEqual(data, source.get(`extension/${name}`), `Review source must contain the exact bytes for ${name}`); + } + fs.appendFileSync(path.join(checkout, ".git/info/exclude"), "\nextension/debug.log\n"); + fs.writeFileSync(path.join(checkout, "extension/debug.log"), "ignored local secret\n"); + assert.equal(git("status", "--porcelain").trim(), "", "The contaminating local file is ignored."); + assert.deepEqual(build(), baseline, "Ignored local files must not affect either archive."); + const crlfCheckout = path.join(temp, "crlf-checkout"); + execFileSync("git", ["clone", "--quiet", "--no-hardlinks", "--config", "core.autocrlf=true", checkout, crlfCheckout]); + checkout = crlfCheckout; + assert.equal(git("status", "--porcelain").trim(), "", "CRLF checkout remains clean."); + assert.ok(fs.readFileSync(path.join(checkout, "extension/manifest.json"), "utf8").includes("\r\n"), + "The fixture must actually exercise checkout byte conversion."); + assert.deepEqual(build(), baseline, "Checkout line-ending filters must not affect committed package bytes."); + console.log(JSON.stringify({ ok: true, exactReviewSource: true, ignoredFilesExcluded: true, checkoutFiltersIgnored: true })); +} finally { + fs.rmSync(temp, { recursive: true, force: true }); +}