123import { createHash } from "node:crypto";4import { existsSync, readFileSync, rmSync } from "node:fs";5import { fileURLToPath } from "node:url";6import { dirname, join, resolve } from "node:path";7import { afterEach, describe, expect, it } from "vitest";89import {10 buildCommand,11 safeEnv,12 cwsUploadEnv,13 amoSignEnv,14 resolveBin,15 preflight,16 packageChromeZip,17 uploadChrome,18 signFirefox,19 CWS_SECRET_NAMES,20 AMO_SECRET_NAMES,21 type CommandSpec,22 type Runner,23} from "./release-lib";2425const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");26const ALL_SECRET_NAMES = [...CWS_SECRET_NAMES, ...AMO_SECRET_NAMES];2728const HELD_ENV: Record<string, string | undefined> = {};2930function setEnv(name: string, val: string) {31 HELD_ENV[name] = process.env[name];32 process.env[name] = val;33}3435function restoreEnv() {36 for (const [name, val] of Object.entries(HELD_ENV)) {37 if (val === undefined) delete process.env[name];38 else process.env[name] = val;39 }40 for (const key of Object.keys(HELD_ENV)) delete HELD_ENV[key];41}4243afterEach(restoreEnv);44454647describe("PB-26-211: argv-based runner — no shell, no npx", () => {48 it("buildCommand returns a CommandSpec with array args, never a string", () => {49 const spec = buildCommand("/bin/echo", ["hello", "world"]);50 expect(Array.isArray(spec.args)).toBe(true);51 expect(spec.args).toEqual(["hello", "world"]);52 expect(typeof spec.executable).toBe("string");53 });5455 it("no release/CI script source contains execSync or npx", () => {56 const scripts = [57 "scripts/release.ts",58 "scripts/release-firefox.ts",59 "scripts/release-lib.ts",60 "scripts/ci.ts",61 ];62 for (const p of scripts) {63 const src = readFileSync(join(ROOT, p), "utf8");64 expect(src, `${p} must not use execSync`).not.toContain("execSync");65 expect(src, `${p} must not invoke npx`).not.toMatch(/\bnpx\b/);66 }67 });6869 it("no release script builds commands via string concatenation (.join)", () => {70 const scripts = [71 "scripts/release.ts",72 "scripts/release-firefox.ts",73 "scripts/release-lib.ts",74 ];75 for (const p of scripts) {76 const src = readFileSync(join(ROOT, p), "utf8");77 expect(src, `${p} must not join command strings`).not.toContain("].join(");78 }79 });8081 it("fake runner captures argv — no captured spec uses a shell or npx", () => {82 const captured: CommandSpec[] = [];83 const fake: Runner = (exec, args, opts) => {84 captured.push(buildCommand(exec, args, opts));85 };8687 for (const name of ALL_SECRET_NAMES) setEnv(name, "secret-value");8889 packageChromeZip("9.9.9");90 uploadChrome("9.9.9", fake);91 signFirefox("/tmp/fake-source.zip", fake);9293 94 95 expect(captured.length).toBeGreaterThanOrEqual(2);96 for (const spec of captured) {97 expect(spec.executable).not.toBe("npx");98 expect(spec.args).not.toContain("npx");99 expect(Array.isArray(spec.args)).toBe(true);100 }101 });102});103104105106describe("PB-26-211: CI/build child environments contain no store credentials", () => {107 it("safeEnv strips all CWS and AMO secret names", () => {108 for (const name of ALL_SECRET_NAMES) setEnv(name, "leak-value");109110 const env = safeEnv();111 for (const name of ALL_SECRET_NAMES) {112 expect(env, `safeEnv must not contain ${name}`).not.toHaveProperty(name);113 }114 });115116 it("safeEnv preserves non-secret environment variables", () => {117 setEnv("PATH", "/usr/bin:/bin");118 setEnv("HOME", "/tmp/test-home");119120 const env = safeEnv();121 expect(env.PATH).toBe("/usr/bin:/bin");122 expect(env.HOME).toBe("/tmp/test-home");123 });124125 it("safeEnv strips build-behavior overrides from release children", () => {126 for (const [name, value] of Object.entries({127 BUILD_TARGET: "firefox",128 PEACH_E2E_PASSKEYS: "1",129 PEACH_DEBUG: "1",130 DEBUG: "vite:*",131 NODE_ENV: "development",132 MODE: "development",133 VITE_MODE: "development",134 CI: "true",135 })) setEnv(name, value);136137 const env = safeEnv();138 for (const name of [139 "BUILD_TARGET",140 "PEACH_E2E_PASSKEYS",141 "PEACH_DEBUG",142 "DEBUG",143 "NODE_ENV",144 "MODE",145 "VITE_MODE",146 "CI",147 ]) expect(env, `safeEnv must not contain ${name}`).not.toHaveProperty(name);148 });149150 it("safeEnv strips the release browser gate from release children", () => {151 setEnv("PEACH_RELEASE_BROWSER_GATE", "0");152 setEnv("BUILD_TARGET", "firefox");153154 const env = safeEnv();155 expect(env).not.toHaveProperty("PEACH_RELEASE_BROWSER_GATE");156 expect(env).not.toHaveProperty("BUILD_TARGET");157 });158});159160161162describe("PB-26-211: Chrome and AMO credentials are disjoint", () => {163 it("cwsUploadEnv contains CWS credentials but no AMO credentials", () => {164 for (const name of ALL_SECRET_NAMES) setEnv(name, "val-" + name);165166 const env = cwsUploadEnv();167168 169 expect(env.CLIENT_ID).toBe("val-CWS_CLIENT_ID");170 expect(env.CLIENT_SECRET).toBe("val-CWS_CLIENT_SECRET");171 expect(env.REFRESH_TOKEN).toBe("val-CWS_REFRESH_TOKEN");172 expect(env.PUBLISHER_ID).toBe("val-PUBLISHER_ID");173174 175 expect(env).not.toHaveProperty("AMO_JWT_ISSUER");176 expect(env).not.toHaveProperty("AMO_JWT_SECRET");177 expect(env).not.toHaveProperty("WEB_EXT_API_KEY");178 expect(env).not.toHaveProperty("WEB_EXT_API_SECRET");179 });180181 it("amoSignEnv contains AMO credentials but no CWS credentials", () => {182 for (const name of ALL_SECRET_NAMES) setEnv(name, "val-" + name);183184 const env = amoSignEnv();185186 187 expect(env.WEB_EXT_API_KEY).toBe("val-AMO_JWT_ISSUER");188 expect(env.WEB_EXT_API_SECRET).toBe("val-AMO_JWT_SECRET");189190 191 expect(env).not.toHaveProperty("CWS_CLIENT_ID");192 expect(env).not.toHaveProperty("CWS_CLIENT_SECRET");193 expect(env).not.toHaveProperty("CWS_REFRESH_TOKEN");194 expect(env).not.toHaveProperty("CWS_EXTENSION_ID");195 expect(env).not.toHaveProperty("PUBLISHER_ID");196 expect(env).not.toHaveProperty("CLIENT_ID");197 expect(env).not.toHaveProperty("CLIENT_SECRET");198 expect(env).not.toHaveProperty("REFRESH_TOKEN");199 });200});201202203204describe("PB-26-211: paths with shell metacharacters remain one argv element", () => {205 it("buildCommand treats a metacharacter path as a single arg", () => {206 const evilPath = "/tmp/$(whoami); echo pwned";207 const spec = buildCommand("/bin/echo", [evilPath]);208 expect(spec.args).toHaveLength(1);209 expect(spec.args[0]).toBe(evilPath);210 });211212 it("spawnSync with argv arrays does not execute shell injections", () => {213 const { spawnSync } = require("node:child_process");214 215 const evilPath = "/tmp/$(whoami)";216 const result = spawnSync("/bin/echo", [evilPath], {217 stdout: "pipe",218 stderr: "pipe",219 shell: false,220 });221 const out = result.stdout?.toString().trim();222 expect(out).toBe(evilPath);223 224 expect(out).toContain("$(whoami)");225 });226});227228229230describe("PB-26-211: missing local binary fails before any network access", () => {231 it("resolveBin throws when the tool is not in node_modules/.bin", () => {232 expect(() => resolveBin("nonexistent-tool-xyz-12345")).toThrow(233 /not found/,234 );235 });236237 it("resolveBin throws before returning a path, preventing network calls", () => {238 let threw = false;239 try {240 resolveBin("another-missing-tool-67890");241 } catch {242 threw = true;243 }244 expect(threw).toBe(true);245 });246});247248249250describe("PB-26-211: preflight rejects non-exact release-tool versions", () => {251 it("preflight passes when release-critical deps are exact", () => {252 253 254 expect(() => preflight()).not.toThrow();255 });256257 it("package.json has exact versions for release-critical dev deps", () => {258 const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));259 const devDeps = pkg.devDependencies ?? {};260 const critical = [261 "chrome-webstore-upload-cli",262 "web-ext",263 "typescript",264 "@playwright/test",265 ];266 for (const dep of critical) {267 const v = devDeps[dep];268 expect(v, `${dep} must be in devDependencies`).toBeDefined();269 expect(v, `${dep} must not use ^ or ~`).not.toMatch(/^[~^]/);270 }271 });272});273274275276describe.skipIf(!existsSync(join(ROOT, "dist-chrome", "manifest.json")))(277 "PB-26-611: Chrome extension archive is reproducible",278 () => {279 afterEach(() => {280 const zipPath = join(ROOT, "artifacts", "peach-chrome-9.9.9.zip");281 if (existsSync(zipPath)) rmSync(zipPath);282 });283284 it("two archives built from the same dist are byte-identical", () => {285 const first = readFileSync(packageChromeZip("9.9.9"));286 const second = readFileSync(packageChromeZip("9.9.9"));287288 expect(first.length).toBeGreaterThan(0);289 expect(createHash("sha256").update(first).digest("hex")).toBe(290 createHash("sha256").update(second).digest("hex"),291 );292 });293294 it("embeds no source maps, .DS_Store or __MACOSX entries", () => {295 const zip = packageChromeZip("9.9.9");296 const bytes = readFileSync(zip);297 const text = bytes.toString("latin1");298299 expect(text).not.toContain(".map");300 expect(text).not.toContain(".DS_Store");301 expect(text).not.toContain("__MACOSX");302 });303304 it("release-lib no longer shells out to the system zip binary", () => {305 const source = readFileSync(join(ROOT, "scripts", "release-lib.ts"), "utf8");306 expect(source).not.toContain('"zip"');307 expect(source).toContain("createDeterministicZip");308 });309 },310);