123import { createHash } from "node:crypto";4import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";5import { tmpdir } from "node:os";6import { fileURLToPath } from "node:url";7import { dirname, join, resolve } from "node:path";8import { afterEach, describe, expect, it } from "vitest";910import {11 buildCommand,12 safeEnv,13 cwsUploadEnv,14 amoSignEnv,15 resolveBin,16 preflight,17 packageChromeZip,18 collectDirectory,19 uploadChrome,20 signFirefox,21 CWS_SECRET_NAMES,22 AMO_SECRET_NAMES,23 type CommandSpec,24 type Runner,25} from "./release-lib";2627const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");28const ALL_SECRET_NAMES = [...CWS_SECRET_NAMES, ...AMO_SECRET_NAMES];2930const HELD_ENV: Record<string, string | undefined> = {};3132function setEnv(name: string, val: string) {33 HELD_ENV[name] = process.env[name];34 process.env[name] = val;35}3637function restoreEnv() {38 for (const [name, val] of Object.entries(HELD_ENV)) {39 if (val === undefined) delete process.env[name];40 else process.env[name] = val;41 }42 for (const key of Object.keys(HELD_ENV)) delete HELD_ENV[key];43}4445afterEach(restoreEnv);46474849describe("PB-26-211: argv-based runner — no shell, no npx", () => {50 it("buildCommand returns a CommandSpec with array args, never a string", () => {51 const spec = buildCommand("/bin/echo", ["hello", "world"]);52 expect(Array.isArray(spec.args)).toBe(true);53 expect(spec.args).toEqual(["hello", "world"]);54 expect(typeof spec.executable).toBe("string");55 });5657 it("no release/CI script source contains execSync or npx", () => {58 const scripts = [59 "scripts/release.ts",60 "scripts/release-firefox.ts",61 "scripts/release-lib.ts",62 "scripts/ci.ts",63 ];64 for (const p of scripts) {65 const src = readFileSync(join(ROOT, p), "utf8");66 expect(src, `${p} must not use execSync`).not.toContain("execSync");67 expect(src, `${p} must not invoke npx`).not.toMatch(/\bnpx\b/);68 }69 });7071 it("no release script builds commands via string concatenation (.join)", () => {72 const scripts = [73 "scripts/release.ts",74 "scripts/release-firefox.ts",75 "scripts/release-lib.ts",76 ];77 for (const p of scripts) {78 const src = readFileSync(join(ROOT, p), "utf8");79 expect(src, `${p} must not join command strings`).not.toContain("].join(");80 }81 });8283 it("fake runner captures argv — no captured spec uses a shell or npx", () => {84 const captured: CommandSpec[] = [];85 const fake: Runner = (exec, args, opts) => {86 captured.push(buildCommand(exec, args, opts));87 };8889 for (const name of ALL_SECRET_NAMES) setEnv(name, "secret-value");9091 packageChromeZip("9.9.9");92 uploadChrome("9.9.9", fake);93 signFirefox("/tmp/fake-source.zip", fake);9495 96 97 expect(captured.length).toBeGreaterThanOrEqual(2);98 for (const spec of captured) {99 expect(spec.executable).not.toBe("npx");100 expect(spec.args).not.toContain("npx");101 expect(Array.isArray(spec.args)).toBe(true);102 }103 });104});105106107108describe("PB-26-211: CI/build child environments contain no store credentials", () => {109 it("safeEnv strips all CWS and AMO secret names", () => {110 for (const name of ALL_SECRET_NAMES) setEnv(name, "leak-value");111112 const env = safeEnv();113 for (const name of ALL_SECRET_NAMES) {114 expect(env, `safeEnv must not contain ${name}`).not.toHaveProperty(name);115 }116 });117118 it("safeEnv preserves non-secret environment variables", () => {119 setEnv("PATH", "/usr/bin:/bin");120 setEnv("HOME", "/tmp/test-home");121122 const env = safeEnv();123 expect(env.PATH).toBe("/usr/bin:/bin");124 expect(env.HOME).toBe("/tmp/test-home");125 });126127 it("safeEnv strips build-behavior overrides from release children", () => {128 for (const [name, value] of Object.entries({129 BUILD_TARGET: "firefox",130 PEACH_E2E_PASSKEYS: "1",131 PEACH_DEBUG: "1",132 DEBUG: "vite:*",133 NODE_ENV: "development",134 MODE: "development",135 VITE_MODE: "development",136 CI: "true",137 })) setEnv(name, value);138139 const env = safeEnv();140 for (const name of [141 "BUILD_TARGET",142 "PEACH_E2E_PASSKEYS",143 "PEACH_DEBUG",144 "DEBUG",145 "NODE_ENV",146 "MODE",147 "VITE_MODE",148 "CI",149 ]) expect(env, `safeEnv must not contain ${name}`).not.toHaveProperty(name);150 });151152 it("safeEnv strips the release browser gate from release children", () => {153 setEnv("PEACH_RELEASE_BROWSER_GATE", "0");154 setEnv("BUILD_TARGET", "firefox");155156 const env = safeEnv();157 expect(env).not.toHaveProperty("PEACH_RELEASE_BROWSER_GATE");158 expect(env).not.toHaveProperty("BUILD_TARGET");159 });160});161162163164describe("PB-26-211: Chrome and AMO credentials are disjoint", () => {165 it("cwsUploadEnv contains CWS credentials but no AMO credentials", () => {166 for (const name of ALL_SECRET_NAMES) setEnv(name, "val-" + name);167168 const env = cwsUploadEnv();169170 171 expect(env.CLIENT_ID).toBe("val-CWS_CLIENT_ID");172 expect(env.CLIENT_SECRET).toBe("val-CWS_CLIENT_SECRET");173 expect(env.REFRESH_TOKEN).toBe("val-CWS_REFRESH_TOKEN");174 expect(env.PUBLISHER_ID).toBe("val-PUBLISHER_ID");175176 177 expect(env).not.toHaveProperty("AMO_JWT_ISSUER");178 expect(env).not.toHaveProperty("AMO_JWT_SECRET");179 expect(env).not.toHaveProperty("WEB_EXT_API_KEY");180 expect(env).not.toHaveProperty("WEB_EXT_API_SECRET");181 });182183 it("amoSignEnv contains AMO credentials but no CWS credentials", () => {184 for (const name of ALL_SECRET_NAMES) setEnv(name, "val-" + name);185186 const env = amoSignEnv();187188 189 expect(env.WEB_EXT_API_KEY).toBe("val-AMO_JWT_ISSUER");190 expect(env.WEB_EXT_API_SECRET).toBe("val-AMO_JWT_SECRET");191192 193 expect(env).not.toHaveProperty("CWS_CLIENT_ID");194 expect(env).not.toHaveProperty("CWS_CLIENT_SECRET");195 expect(env).not.toHaveProperty("CWS_REFRESH_TOKEN");196 expect(env).not.toHaveProperty("CWS_EXTENSION_ID");197 expect(env).not.toHaveProperty("PUBLISHER_ID");198 expect(env).not.toHaveProperty("CLIENT_ID");199 expect(env).not.toHaveProperty("CLIENT_SECRET");200 expect(env).not.toHaveProperty("REFRESH_TOKEN");201 });202});203204205206describe("PB-26-211: paths with shell metacharacters remain one argv element", () => {207 it("buildCommand treats a metacharacter path as a single arg", () => {208 const evilPath = "/tmp/$(whoami); echo pwned";209 const spec = buildCommand("/bin/echo", [evilPath]);210 expect(spec.args).toHaveLength(1);211 expect(spec.args[0]).toBe(evilPath);212 });213214 it("spawnSync with argv arrays does not execute shell injections", () => {215 const { spawnSync } = require("node:child_process");216 217 const evilPath = "/tmp/$(whoami)";218 const result = spawnSync("/bin/echo", [evilPath], {219 stdout: "pipe",220 stderr: "pipe",221 shell: false,222 });223 const out = result.stdout?.toString().trim();224 expect(out).toBe(evilPath);225 226 expect(out).toContain("$(whoami)");227 });228});229230231232describe("PB-26-211: missing local binary fails before any network access", () => {233 it("resolveBin throws when the tool is not in node_modules/.bin", () => {234 expect(() => resolveBin("nonexistent-tool-xyz-12345")).toThrow(235 /not found/,236 );237 });238239 it("resolveBin throws before returning a path, preventing network calls", () => {240 let threw = false;241 try {242 resolveBin("another-missing-tool-67890");243 } catch {244 threw = true;245 }246 expect(threw).toBe(true);247 });248});249250251252describe("PB-26-211: preflight rejects non-exact release-tool versions", () => {253 it("preflight passes when release-critical deps are exact", () => {254 255 256 expect(() => preflight()).not.toThrow();257 });258259 it("package.json has exact versions for release-critical dev deps", () => {260 const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));261 const devDeps = pkg.devDependencies ?? {};262 const critical = [263 "chrome-webstore-upload-cli",264 "web-ext",265 "typescript",266 "@playwright/test",267 ];268 for (const dep of critical) {269 const v = devDeps[dep];270 expect(v, `${dep} must be in devDependencies`).toBeDefined();271 expect(v, `${dep} must not use ^ or ~`).not.toMatch(/^[~^]/);272 }273 });274});275276277278describe.skipIf(!existsSync(join(ROOT, "dist-chrome", "manifest.json")))(279 "PB-26-611: Chrome extension archive is reproducible",280 () => {281 afterEach(() => {282 const zipPath = join(ROOT, "artifacts", "peach-chrome-9.9.9.zip");283 if (existsSync(zipPath)) rmSync(zipPath);284 });285286 it("two archives built from the same dist are byte-identical", () => {287 const first = readFileSync(packageChromeZip("9.9.9"));288 const second = readFileSync(packageChromeZip("9.9.9"));289290 expect(first.length).toBeGreaterThan(0);291 expect(createHash("sha256").update(first).digest("hex")).toBe(292 createHash("sha256").update(second).digest("hex"),293 );294 });295296 it("preserves paths relative to the distribution root", () => {297 const fixture = join(tmpdir(), `peach-release-paths-${process.pid}`);298 rmSync(fixture, { recursive: true, force: true });299 mkdirSync(join(fixture, "assets"), { recursive: true });300 mkdirSync(join(fixture, "icons"), { recursive: true });301 writeFileSync(join(fixture, "manifest.json"), "{}");302 writeFileSync(join(fixture, "assets", "entry.js"), "export {};");303 writeFileSync(join(fixture, "icons", "16.png"), "icon");304305 try {306 expect(collectDirectory(fixture).map((entry) => entry.path)).toEqual([307 "assets/entry.js",308 "icons/16.png",309 "manifest.json",310 ]);311 } finally {312 rmSync(fixture, { recursive: true, force: true });313 }314 });315316 it("embeds no source maps, .DS_Store or __MACOSX entries", () => {317 const zip = packageChromeZip("9.9.9");318 const bytes = readFileSync(zip);319 const text = bytes.toString("latin1");320321 expect(text).not.toContain(".map");322 expect(text).not.toContain(".DS_Store");323 expect(text).not.toContain("__MACOSX");324 });325326 it("release-lib no longer shells out to the system zip binary", () => {327 const source = readFileSync(join(ROOT, "scripts", "release-lib.ts"), "utf8");328 expect(source).not.toContain('"zip"');329 expect(source).toContain("createDeterministicZip");330 });331 },332);