From ed0e2eff431062244896f5e1c8eabd6394ec237d Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Tue, 2 Jun 2026 12:35:05 +0200 Subject: [PATCH] Use update manifest for extension setup --- README.md | 4 +- docs/all-commands-qa.md | 2 +- docs/architecture.md | 4 +- docs/development.md | 2 +- docs/firefox-cli-spec.md | 8 +- docs/setup.md | 6 +- packages/cli/src/cli-setup-doctor.test.ts | 125 ++++++-- packages/cli/src/cli-test-support.ts | 30 +- packages/cli/src/commands/setup-doctor.ts | 28 +- packages/cli/src/extension-updates.ts | 91 ++++++ packages/cli/src/help.ts | 2 +- packages/cli/src/types.ts | 2 +- packages/protocol/src/constants.ts | 1 + scripts/extension-display-metadata.ts | 3 +- scripts/extension-payload-check.ts | 31 +- scripts/extension-update-manifest.ts | 140 +++++++++ scripts/package-check.ts | 294 ++----------------- scripts/package.ts | 64 +--- scripts/prepare-release-version.ts | 2 + scripts/release-check.ts | 47 ++- scripts/test/package-check-test-utils.ts | 64 ++-- scripts/test/package-check.test.ts | 283 ++---------------- scripts/test/prepare-release-version.test.ts | 116 +++++++- 23 files changed, 638 insertions(+), 711 deletions(-) create mode 100644 packages/cli/src/extension-updates.ts create mode 100644 scripts/extension-update-manifest.ts diff --git a/README.md b/README.md index f092cd0..25ed009 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,13 @@ Install the CLI package: npm install -g firefox-cli ``` -Print the extension path and native-host setup guidance: +Print the extension download URL and native-host setup guidance: ```sh firefox-cli setup ``` -Install the extension shown by `firefox-cli setup` - a signed `extension/firefox-cli.xpi`; open it in Firefox and accept the install prompt. +Install the extension from the URL shown by `firefox-cli setup`; open it in Firefox and accept the install prompt. The URL is selected from the update manifest for the matching CLI version. Register the native messaging host: diff --git a/docs/all-commands-qa.md b/docs/all-commands-qa.md index 7ed276c..a808f24 100644 --- a/docs/all-commands-qa.md +++ b/docs/all-commands-qa.md @@ -31,7 +31,7 @@ Create `UPLOAD_FILE` with any small text payload. Capture IDs from JSON output w ## Steps - [ ] Run `$CLI --version`; expect version text and exit code 0. -- [ ] Run `$CLI setup --json`; expect extension and native-host manifest paths. +- [ ] Run `$CLI setup --json`; expect an extension install URL and native-host manifest path. - [ ] Run `$CLI setup native-host --dry-run --json`; expect a manifest plan and `"dryRun": true`. - [ ] Run `$CLI setup native-host --json`; expect the disposable native-host manifest to be written. - [ ] Run `$CLI doctor --json`; expect the disposable extension connection to be `"connected"`. diff --git a/docs/architecture.md b/docs/architecture.md index 7069985..ab8e25a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,8 +44,8 @@ The npm package contains: - `bin/firefox-cli.js`, the user-facing launcher; - `bin//`, the native executable for the package platform; - `lib/platform-binary.js`, runtime platform-binary resolution; -- `extension/development`, an unsigned extension directory for local loading; -- `extension/firefox-cli.xpi`, when a signed release extension artifact is packaged. + +The signed extension XPI is distributed through the update manifest URL reported by `firefox-cli setup`. The setup command selects the download whose extension version matches the CLI version. `doctor --fix` repairs native-host manifests after package moves or upgrades. diff --git a/docs/development.md b/docs/development.md index 285c14d..e2be66a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -38,4 +38,4 @@ Release signing: - `bun run extension:sign` signs `dist/extension` with `web-ext sign`. - Set `WEB_EXT_JWT_ISSUER` to the Mozilla Add-ons JWT issuer and `WEB_EXT_JWT_SECRET` to the corresponding JWT secret. -- `bun run release:check:signed` requires `dist/package/extension/firefox-cli.xpi` in the assembled package. +- `bun run release:check:signed` requires a signed `dist/extension-artifacts/firefox-cli-.xpi` artifact and matching provenance. diff --git a/docs/firefox-cli-spec.md b/docs/firefox-cli-spec.md index 2a78542..79942c2 100644 --- a/docs/firefox-cli-spec.md +++ b/docs/firefox-cli-spec.md @@ -20,7 +20,7 @@ The CLI supports full browser control where Firefox WebExtensions allow it: navi The happy path: 1. User installs the npm package and gets one executable: `firefox-cli`. -2. User manually installs or temporarily loads the Firefox extension from the URL/path printed by `firefox-cli setup`. +2. User manually installs or temporarily loads the Firefox extension from the URL printed by `firefox-cli setup`. 3. User runs `firefox-cli setup native-host` or `firefox-cli doctor --fix` to register the native messaging host. 4. User opens the extension popup and approves the first connection after seeing native-host identity details. 5. Commands control the active Firefox tab/window unless a command or flag selects another target. @@ -334,7 +334,7 @@ Global options: Setup and diagnostics: -- `firefox-cli setup`: print extension install/load instructions and native-host setup status. +- `firefox-cli setup`: print extension download instructions and native-host setup status. - `firefox-cli setup native-host`: register/update the native messaging host. - `firefox-cli doctor`: diagnose extension install, native manifest, host path, extension connection, approval, Firefox status, and protocol version. - `firefox-cli doctor --fix`: repair native-host registration when possible. @@ -478,7 +478,7 @@ Batch: Errors should be concise and actionable: -- If extension is not installed: print the install/load link or local path. +- If extension is not installed: print the matching extension download URL. - If native host is not registered: print `firefox-cli setup native-host`. - If Firefox is not running or extension is disconnected: tell the user to open Firefox and check the extension popup. - If first-use approval is pending: tell the user to open the extension popup and approve. @@ -551,7 +551,7 @@ Package requirements: - Postinstall may print setup guidance but must not silently mutate Firefox configuration without an explicit setup command. - `doctor --fix` handles moved package paths after npm upgrades. - Host-mode stdout is reserved for native messaging frames. -- Release-candidate package verification requires `extension/firefox-cli.xpi` in the assembled package. +- Release-candidate verification requires a signed `dist/extension-artifacts/firefox-cli-.xpi` artifact and matching provenance. Manual extension install is in scope; Mozilla store/public listing automation is out of scope. diff --git a/docs/setup.md b/docs/setup.md index b22f5f9..4e60bf3 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -14,7 +14,7 @@ npm install -g firefox-cli firefox-cli setup ``` -`firefox-cli setup` prints the extension artifact path and native-host setup guidance. `firefox-cli setup --json` also includes the planned native-host manifest path. +`firefox-cli setup` prints the extension download URL and native-host setup guidance. `firefox-cli setup --json` also includes the planned native-host manifest path. Repository checkouts use: @@ -26,9 +26,9 @@ node dist/package/bin/firefox-cli.js setup ## Install The Extension -Release packages include `extension/firefox-cli.xpi` when the signed XPI artifact is present. Open that file in Firefox and accept the install prompt. +Open the extension URL printed by `firefox-cli setup` in Firefox and accept the install prompt. The URL points at the signed XPI for the matching CLI version. -Development packages include an unsigned extension directory: +Repository checkouts can build an unsigned extension directory: ```sh bun run extension:build diff --git a/packages/cli/src/cli-setup-doctor.test.ts b/packages/cli/src/cli-setup-doctor.test.ts index 77a3995..7ffa529 100644 --- a/packages/cli/src/cli-setup-doctor.test.ts +++ b/packages/cli/src/cli-setup-doctor.test.ts @@ -1,9 +1,9 @@ -import { access, mkdir, readFile, writeFile } from "node:fs/promises"; -import { join, posix } from "node:path"; +import { access, readFile, writeFile } from "node:fs/promises"; +import { posix } from "node:path"; import { createErrorResponse } from "@firefox-cli/protocol"; import { createTempDir } from "@firefox-cli/test-support"; import { describe, expect, it } from "vitest"; -import { baseDependencies, baseDependenciesWithoutExtensionPath, parseSetupDryRunOutput } from "./cli-test-support.js"; +import { baseDependencies, extensionUpdatesForVersion, parseSetupDryRunOutput } from "./cli-test-support.js"; import { runCli } from "./index.js"; describe("runCli setup and doctor", () => { @@ -23,17 +23,14 @@ describe("runCli setup and doctor", () => { await expect(readFile(darwinManifestPath(homeDir), "utf8")).resolves.toContain(binaryPath); }); - it("prints setup guidance with extension artifact location", async () => { - const output = await runCli(["setup"], { - ...baseDependencies(), - extensionPath: "/opt/firefox-cli/extension/development", - }); + it("prints setup guidance with a matching extension download URL", async () => { + const output = await runCli(["setup"], baseDependencies()); expect(output).toEqual({ exitCode: 0, stdout: [ "firefox-cli setup", - "Extension: load /opt/firefox-cli/extension/development in Firefox about:debugging.", + "Extension: download and install https://github.com/respawn-llc/firefox-cli/releases/download/v0.0.0/firefox-cli-0.0.0.xpi in Firefox.", "Native host: run `firefox-cli setup native-host`.", "", ].join("\n"), @@ -41,25 +38,93 @@ describe("runCli setup and doctor", () => { }); }); - it("prefers packaged signed extension path in setup guidance", async () => { - const packageRoot = await createTempDir("firefox-cli-package"); - await mkdir(join(packageRoot, "extension"), { recursive: true }); - await writeFile(join(packageRoot, "extension/firefox-cli.xpi"), "signed xpi\n"); - const dependencies = baseDependenciesWithoutExtensionPath(); - + it("prints setup JSON with a matching extension download URL", async () => { const jsonOutput = await runCli(["setup", "--json"], { - ...dependencies, - packageRoot, - }); - const textOutput = await runCli(["setup"], { - ...dependencies, - packageRoot, + ...baseDependencies(), + version: "0.1.1", + fetchExtensionUpdates: async () => extensionUpdatesForVersion("0.1.1"), }); expect(JSON.parse(jsonOutput.stdout)).toMatchObject({ - extensionPath: join(packageRoot, "extension/firefox-cli.xpi"), + extensionInstallUrl: "https://github.com/respawn-llc/firefox-cli/releases/download/v0.1.1/firefox-cli-0.1.1.xpi", + }); + }); + + it("rejects setup guidance when the update manifest lacks the CLI version", async () => { + const output = await runCli(["setup"], { + ...baseDependencies(), + version: "0.1.1", + fetchExtensionUpdates: async () => extensionUpdatesForVersion("0.1.0"), + }); + + expect(output).toEqual({ + exitCode: 1, + stdout: "", + stderr: "No firefox-cli extension download found for CLI version 0.1.1 in https://opensource.respawn.pro/firefox-cli/updates.json.\n", + }); + }); + + it("rejects setup guidance when the matching update version is duplicated", async () => { + const output = await runCli(["setup"], { + ...baseDependencies(), + fetchExtensionUpdates: async () => ({ + addons: { + "ff-cli-bridge@respawn.pro": { + updates: [ + { version: "0.0.0", update_link: "https://example.invalid/one.xpi" }, + { version: "0.0.0", update_link: "https://example.invalid/two.xpi" }, + ], + }, + }, + }), + }); + + expect(output).toEqual({ + exitCode: 1, + stdout: "", + stderr: "Invalid firefox-cli extension update manifest: duplicate entries for version 0.0.0.\n", + }); + }); + + it("rejects setup guidance when the update manifest is malformed", async () => { + const output = await runCli(["setup"], { + ...baseDependencies(), + fetchExtensionUpdates: async () => ({ addons: {} }), + }); + + expect(output).toEqual({ + exitCode: 1, + stdout: "", + stderr: "Invalid firefox-cli extension update manifest: expected extension update manifest add-on ff-cli-bridge@respawn.pro to be an object.\n", + }); + }); + + it("rejects setup guidance when the matching update URL is not HTTPS", async () => { + const output = await runCli(["setup"], { + ...baseDependencies(), + fetchExtensionUpdates: async () => extensionUpdatesForVersion("0.0.0", "http://example.invalid/firefox-cli-0.0.0.xpi"), + }); + + expect(output).toEqual({ + exitCode: 1, + stdout: "", + stderr: "Invalid firefox-cli extension download URL for version 0.0.0: expected HTTPS URL.\n", + }); + }); + + it("reports update manifest fetch failures during setup", async () => { + const output = await runCli(["setup"], { + ...baseDependencies(), + fetchExtensionUpdates: async () => { + throw new Error("offline"); + }, + }); + + expect(output).toEqual({ + exitCode: 1, + stdout: "", + stderr: "Failed to fetch firefox-cli extension update manifest from https://opensource.respawn.pro/firefox-cli/updates.json: offline\n", }); - expect(textOutput.stdout).toContain(`Extension: install ${join(packageRoot, "extension/firefox-cli.xpi")} in Firefox.`); }); it("prints setup native-host dry-run JSON without writing the manifest", async () => { @@ -78,6 +143,20 @@ describe("runCli setup and doctor", () => { await expect(access(parsed.manifestPath)).rejects.toMatchObject({ code: "ENOENT" }); }); + it("does not fetch extension update metadata for native-host setup", async () => { + const homeDir = await createTempDir("firefox-cli-home"); + const output = await runCli(["setup", "native-host", "--dry-run"], { + ...baseDependencies(), + homeDir, + fetchExtensionUpdates: async () => { + throw new Error("network should not be used"); + }, + }); + + expect(output.exitCode).toBe(0); + expect(output.stdout).toContain("Native host manifest planned:"); + }); + it("reports doctor setup state and fixes a missing manifest", async () => { const homeDir = await createTempDir("firefox-cli-home"); const binaryPath = "/opt/firefox-cli/bin/darwin-arm64/firefox-cli"; diff --git a/packages/cli/src/cli-test-support.ts b/packages/cli/src/cli-test-support.ts index bbeff9b..d87efac 100644 --- a/packages/cli/src/cli-test-support.ts +++ b/packages/cli/src/cli-test-support.ts @@ -8,9 +8,9 @@ export function baseDependencies(): CliDependencies { arch: "arm64", homeDir: "/Users/tester", binaryPath: "/opt/firefox-cli/bin/darwin-arm64/firefox-cli", - extensionPath: "/opt/firefox-cli/extension/development", packageRoot: "/opt/firefox-cli", cwd: "/work", + fetchExtensionUpdates: async () => extensionUpdatesForVersion("0.0.0"), sendRequest: async (request) => createErrorResponse(request.id, { code: "NATIVE_HOST_UNAVAILABLE", @@ -20,21 +20,21 @@ export function baseDependencies(): CliDependencies { }; } -export function baseDependenciesWithoutExtensionPath(): CliDependencies { +export function extensionUpdatesForVersion( + version: string, + updateLink = `https://github.com/respawn-llc/firefox-cli/releases/download/v${version}/firefox-cli-${version}.xpi`, +) { return { - version: "0.0.0", - platform: "darwin", - arch: "arm64", - homeDir: "/Users/tester", - binaryPath: "/opt/firefox-cli/bin/darwin-arm64/firefox-cli", - packageRoot: "/opt/firefox-cli", - cwd: "/work", - sendRequest: async (request) => - createErrorResponse(request.id, { - code: "NATIVE_HOST_UNAVAILABLE", - message: "firefox-cli native host is not running.", - }), - clearPairState: async () => undefined, + addons: { + "ff-cli-bridge@respawn.pro": { + updates: [ + { + version, + update_link: updateLink, + }, + ], + }, + }, }; } diff --git a/packages/cli/src/commands/setup-doctor.ts b/packages/cli/src/commands/setup-doctor.ts index 23934ea..ccf7ffe 100644 --- a/packages/cli/src/commands/setup-doctor.ts +++ b/packages/cli/src/commands/setup-doctor.ts @@ -1,5 +1,4 @@ -import { access, readFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import { readFile } from "node:fs/promises"; import { isPersistedJsonFileError, parseNativeMessagingManifestJson, @@ -7,6 +6,7 @@ import { writeNativeMessagingManifest, } from "@firefox-cli/native-host"; import { optionalAppDataDir } from "../default-dependencies.js"; +import { resolveExtensionInstallUrl } from "../extension-updates.js"; import { ok } from "../result.js"; import { createNoopRequest, sendOrUnavailable } from "../transport.js"; import type { CliDependencies, CliResult } from "../types.js"; @@ -18,12 +18,12 @@ export async function setup(args: readonly string[], dependencies: CliDependenci if (args.length === 0 || args.includes("--json")) { const plan = await createManifestPlan(dependencies); - const extensionPath = await resolveExtensionInstallPath(dependencies); + const extensionInstallUrl = await resolveExtensionInstallUrl(dependencies.version, dependencies.fetchExtensionUpdates); if (args.includes("--json")) { return ok( `${JSON.stringify( { - extensionPath, + extensionInstallUrl, nativeHostManifestPath: plan.manifestPath, }, null, @@ -32,7 +32,7 @@ export async function setup(args: readonly string[], dependencies: CliDependenci ); } - return ok(["firefox-cli setup", formatExtensionSetupInstruction(extensionPath), "Native host: run `firefox-cli setup native-host`.", ""].join("\n")); + return ok(["firefox-cli setup", formatExtensionSetupInstruction(extensionInstallUrl), "Native host: run `firefox-cli setup native-host`.", ""].join("\n")); } if (args[0] !== "native-host") { @@ -129,22 +129,8 @@ async function createManifestPlan(dependencies: CliDependencies) { }); } -async function resolveExtensionInstallPath(dependencies: CliDependencies): Promise { - if (dependencies.extensionPath !== undefined) { - return dependencies.extensionPath; - } - - const signedXpiPath = resolve(dependencies.packageRoot, "extension/firefox-cli.xpi"); - try { - await access(signedXpiPath); - return signedXpiPath; - } catch { - return resolve(dependencies.packageRoot, "extension/development"); - } -} - -function formatExtensionSetupInstruction(extensionPath: string): string { - return extensionPath.endsWith(".xpi") ? `Extension: install ${extensionPath} in Firefox.` : `Extension: load ${extensionPath} in Firefox about:debugging.`; +function formatExtensionSetupInstruction(extensionInstallUrl: string): string { + return `Extension: download and install ${extensionInstallUrl} in Firefox.`; } async function readNativeHostManifestStatus(plan: Awaited>): Promise< diff --git a/packages/cli/src/extension-updates.ts b/packages/cli/src/extension-updates.ts new file mode 100644 index 0000000..d717b80 --- /dev/null +++ b/packages/cli/src/extension-updates.ts @@ -0,0 +1,91 @@ +import { FIREFOX_CLI_EXTENSION_ID, FIREFOX_CLI_EXTENSION_UPDATE_URL } from "@firefox-cli/protocol"; +import { CliUsageError } from "./types.js"; + +const extensionUpdatesFetchTimeoutMs = 10_000; + +export type ExtensionUpdatesFetcher = () => Promise; + +export async function resolveExtensionInstallUrl(version: string, fetchUpdates: ExtensionUpdatesFetcher = fetchExtensionUpdates): Promise { + const metadata = await fetchUpdatesWithUsageError(fetchUpdates); + const update = findMatchingExtensionUpdate(metadata, version); + if (update === undefined) { + throw new CliUsageError(`No firefox-cli extension download found for CLI version ${version} in ${FIREFOX_CLI_EXTENSION_UPDATE_URL}.`); + } + return update.updateLink; +} + +async function fetchUpdatesWithUsageError(fetchUpdates: ExtensionUpdatesFetcher): Promise { + try { + return await fetchUpdates(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new CliUsageError(`Failed to fetch firefox-cli extension update manifest from ${FIREFOX_CLI_EXTENSION_UPDATE_URL}: ${message}`); + } +} + +async function fetchExtensionUpdates(): Promise { + const response = await fetch(FIREFOX_CLI_EXTENSION_UPDATE_URL, { + signal: AbortSignal.timeout(extensionUpdatesFetchTimeoutMs), + }); + if (!response.ok) { + throw new Error(`HTTP ${String(response.status)} ${response.statusText}`); + } + return response.json(); +} + +function findMatchingExtensionUpdate(metadata: unknown, version: string): { readonly updateLink: string } | undefined { + const updates = parseExtensionUpdates(metadata); + const matches = updates.filter((update) => update.version === version); + if (matches.length > 1) { + throw new CliUsageError(`Invalid firefox-cli extension update manifest: duplicate entries for version ${version}.`); + } + return matches[0]; +} + +function parseExtensionUpdates(metadata: unknown): readonly { readonly version: string; readonly updateLink: string }[] { + const root = requireRecord(metadata, "extension update manifest"); + const addons = requireRecord(root.addons, "extension update manifest addons"); + const extensionMetadata = requireRecord(addons[FIREFOX_CLI_EXTENSION_ID], `extension update manifest add-on ${FIREFOX_CLI_EXTENSION_ID}`); + const updates = requireArray(extensionMetadata.updates, `extension update manifest add-on ${FIREFOX_CLI_EXTENSION_ID} updates`); + return updates.map(parseExtensionUpdate); +} + +function parseExtensionUpdate(value: unknown, index: number): { readonly version: string; readonly updateLink: string } { + const label = `extension update manifest update ${String(index)}`; + const update = requireRecord(value, label); + const version = requireString(update.version, `${label} version`); + const updateLink = requireString(update.update_link, `${label} update_link`); + if (!isHttpsUrl(updateLink)) { + throw new CliUsageError(`Invalid firefox-cli extension download URL for version ${version}: expected HTTPS URL.`); + } + return { version, updateLink }; +} + +function requireRecord(value: unknown, label: string): Readonly> { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new CliUsageError(`Invalid firefox-cli extension update manifest: expected ${label} to be an object.`); + } + return Object.fromEntries(Object.entries(value)); +} + +function requireArray(value: unknown, label: string): readonly unknown[] { + if (!Array.isArray(value)) { + throw new CliUsageError(`Invalid firefox-cli extension update manifest: expected ${label} to be an array.`); + } + return value; +} + +function requireString(value: unknown, label: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new CliUsageError(`Invalid firefox-cli extension update manifest: expected ${label} to be a non-empty string.`); + } + return value; +} + +function isHttpsUrl(value: string): boolean { + try { + return new URL(value).protocol === "https:"; + } catch { + return false; + } +} diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index f377a5a..484ec56 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -180,7 +180,7 @@ const builtinHelpSpecs = new Map([ [ "setup", helpSpec("Print extension installation guidance or register the native messaging host.", [ - "`firefox-cli setup` prints the extension path and native-host setup command.", + "`firefox-cli setup` prints the matching extension download URL and native-host setup command.", "`firefox-cli setup native-host` writes the per-user native messaging manifest.", ]), ], diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 0adb37a..354aa56 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -30,9 +30,9 @@ export interface CliDependencies { readonly appDataDir?: string; readonly packageRoot: string; readonly binaryPath?: string; - readonly extensionPath?: string; readonly cwd?: string; sendRequest?(request: RequestEnvelope): Promise; + readonly fetchExtensionUpdates?: () => Promise; readStdin?(): Promise; statUploadFile?(path: string): Promise; readUploadFile?(path: string, limits: UploadReadLimits): Promise; diff --git a/packages/protocol/src/constants.ts b/packages/protocol/src/constants.ts index c262c76..04c7fca 100644 --- a/packages/protocol/src/constants.ts +++ b/packages/protocol/src/constants.ts @@ -1,6 +1,7 @@ export const PRODUCT_NAME = "firefox-cli"; export const NATIVE_HOST_NAME = "firefox_cli"; export const FIREFOX_CLI_EXTENSION_ID = "ff-cli-bridge@respawn.pro"; +export const FIREFOX_CLI_EXTENSION_UPDATE_URL = "https://opensource.respawn.pro/firefox-cli/updates.json"; export const PROTOCOL_VERSION = 3; export const PROTOCOL_MIN_VERSION = 1; export const PROTOCOL_MAX_VERSION = PROTOCOL_VERSION; diff --git a/scripts/extension-display-metadata.ts b/scripts/extension-display-metadata.ts index 0eb80dc..667b6a8 100644 --- a/scripts/extension-display-metadata.ts +++ b/scripts/extension-display-metadata.ts @@ -1,10 +1,11 @@ +import { FIREFOX_CLI_EXTENSION_UPDATE_URL } from "@firefox-cli/protocol"; import type { ExtensionManifest } from "./manifest-validation.js"; export const extensionDisplayMetadata = { name: "FF-CLI Bridge", description: "Browser extension bridge for CLI control.", actionTitle: "FF-CLI Bridge", - updateUrl: "https://opensource.respawn.pro/firefox-cli/updates.json", + updateUrl: FIREFOX_CLI_EXTENSION_UPDATE_URL, } as const; const amoTrademarkPattern = /\b(?:firefox|mozilla)\b/iu; diff --git a/scripts/extension-payload-check.ts b/scripts/extension-payload-check.ts index dfc90c7..fdf3a28 100644 --- a/scripts/extension-payload-check.ts +++ b/scripts/extension-payload-check.ts @@ -33,32 +33,45 @@ export async function verifyExtensionBundlePayload(payload: ReadonlyMap): Promise { const developmentPayload = await readDevelopmentExtensionPayload(packageRoot); - const developmentFiles = [...developmentPayload.keys()].sort(); + verifyPayloadMatchesExpectedPayload(developmentPayload, xpiPayload, "package file"); +} + +export async function verifyPayloadMatchesExtensionSource(sourceDir: string, xpiPayload: ReadonlyMap): Promise { + const sourcePayload = await readExtensionPayload(sourceDir, "extension source payload"); + verifyPayloadMatchesExpectedPayload(sourcePayload, xpiPayload, "source file"); +} + +function verifyPayloadMatchesExpectedPayload(expectedPayload: ReadonlyMap, xpiPayload: ReadonlyMap, label: string): void { + const expectedFiles = [...expectedPayload.keys()].sort(); const xpiFiles = [...xpiPayload.keys()].sort(); - const missingFiles = developmentFiles.filter((file) => !xpiPayload.has(file)); - const unexpectedFiles = xpiFiles.filter((file) => !developmentPayload.has(file)); + const missingFiles = expectedFiles.filter((file) => !xpiPayload.has(file)); + const unexpectedFiles = xpiFiles.filter((file) => !expectedPayload.has(file)); if (missingFiles.length > 0) { - throw new Error(`Signed extension XPI is missing package files: ${missingFiles.join(", ")}`); + throw new Error(`Signed extension XPI is missing ${label}s: ${missingFiles.join(", ")}`); } if (unexpectedFiles.length > 0) { - throw new Error(`Signed extension XPI contains files outside the package payload: ${unexpectedFiles.join(", ")}`); + throw new Error(`Signed extension XPI contains files outside the expected payload: ${unexpectedFiles.join(", ")}`); } - for (const [file, expected] of developmentPayload) { + for (const [file, expected] of expectedPayload) { const actual = xpiPayload.get(file); if (!actual?.equals(expected)) { - throw new Error(`Signed extension XPI payload differs from package file: ${file}`); + throw new Error(`Signed extension XPI payload differs from ${label}: ${file}`); } } } export async function readDevelopmentExtensionPayload(packageRoot: string): Promise> { const extensionRoot = resolve(packageRoot, "extension/development"); + return readExtensionPayload(extensionRoot, "development extension payload"); +} + +async function readExtensionPayload(extensionRoot: string, label: string): Promise> { const packageOnlyFiles = new Set(["README.md", `firefox-cli-${rootPackage.version}.zip`]); - const files = (await listRegularFilesUnder(extensionRoot, "development extension payload")).filter((file) => !packageOnlyFiles.has(file.relativePath)); + const files = (await listRegularFilesUnder(extensionRoot, label)).filter((file) => !packageOnlyFiles.has(file.relativePath)); const payload = await Promise.all( - files.map(async (file) => [file.relativePath, await readRegularFileUnder(extensionRoot, file.relativePath, "development extension payload")] as const), + files.map(async (file) => [file.relativePath, await readRegularFileUnder(extensionRoot, file.relativePath, label)] as const), ); return new Map(payload); } diff --git a/scripts/extension-update-manifest.ts b/scripts/extension-update-manifest.ts new file mode 100644 index 0000000..4747b30 --- /dev/null +++ b/scripts/extension-update-manifest.ts @@ -0,0 +1,140 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { FIREFOX_CLI_EXTENSION_ID } from "@firefox-cli/protocol"; + +export const extensionUpdateManifestPath = "docs/firefox-cli/updates.json"; + +export function extensionReleaseXpiUrl(version: string): string { + return `https://github.com/respawn-llc/firefox-cli/releases/download/v${version}/firefox-cli-${version}.xpi`; +} + +export async function syncExtensionUpdateManifest(options: { readonly root: string; readonly version: string }): Promise { + const path = join(options.root, extensionUpdateManifestPath); + const before = await readFile(path, "utf8").catch((error: unknown) => { + if (isNodeError(error, "ENOENT")) { + return undefined; + } + throw error; + }); + const manifest = before === undefined ? createEmptyManifest() : parseUpdateManifest(before, path); + const addon = ensureAddon(manifest); + const existingUpdate = addon.updates.find((update) => update.version === options.version); + const nextUpdate = { + ...cloneUpdateTemplate(addon.updates.at(-1)), + ...existingUpdate, + version: options.version, + update_link: extensionReleaseXpiUrl(options.version), + }; + const filteredUpdates = addon.updates.filter((update) => update.version !== options.version); + addon.updates = [...filteredUpdates, nextUpdate].sort((left, right) => left.version.localeCompare(right.version)); + const after = `${JSON.stringify(manifest, null, 2)}\n`; + if (before === after) { + return []; + } + await writeFile(path, after); + return [extensionUpdateManifestPath]; +} + +export async function verifyExtensionUpdateManifestEntry(options: { readonly root: string; readonly version: string }): Promise { + const path = join(options.root, extensionUpdateManifestPath); + const manifest = parseUpdateManifest(await readFile(path, "utf8"), path); + const addon = requireAddon(manifest); + const matches = addon.updates.filter((update) => update.version === options.version); + if (matches.length === 0) { + throw new Error(`Expected ${extensionUpdateManifestPath} to include extension version ${options.version}`); + } + if (matches.length > 1) { + throw new Error(`Expected ${extensionUpdateManifestPath} to contain one entry for extension version ${options.version}`); + } + const expectedUrl = extensionReleaseXpiUrl(options.version); + if (matches[0]?.update_link !== expectedUrl) { + throw new Error(`Expected ${extensionUpdateManifestPath} version ${options.version} update_link ${expectedUrl}`); + } +} + +interface ExtensionUpdateManifest extends Record { + readonly addons: Record; +} + +interface ExtensionUpdateAddon extends Record { + updates: ExtensionUpdate[]; +} + +interface ExtensionUpdate extends Record { + version: string; + update_link: string; +} + +function createEmptyManifest(): ExtensionUpdateManifest { + return { + addons: { + [FIREFOX_CLI_EXTENSION_ID]: { + updates: [], + }, + }, + }; +} + +function ensureAddon(manifest: ExtensionUpdateManifest): ExtensionUpdateAddon { + const existing = manifest.addons[FIREFOX_CLI_EXTENSION_ID]; + if (existing !== undefined) { + return existing; + } + const addon = { updates: [] }; + manifest.addons[FIREFOX_CLI_EXTENSION_ID] = addon; + return addon; +} + +function requireAddon(manifest: ExtensionUpdateManifest): ExtensionUpdateAddon { + const addon = manifest.addons[FIREFOX_CLI_EXTENSION_ID]; + if (addon === undefined) { + throw new Error(`Expected ${extensionUpdateManifestPath} to include add-on ${FIREFOX_CLI_EXTENSION_ID}`); + } + return addon; +} + +function cloneUpdateTemplate(update: ExtensionUpdate | undefined): Partial { + return update?.applications === undefined ? {} : { applications: update.applications }; +} + +function parseUpdateManifest(content: string, path: string): ExtensionUpdateManifest { + const parsed: unknown = JSON.parse(content); + if (!isRecord(parsed) || !isRecord(parsed.addons)) { + throw new Error(`${path} must contain an addons object.`); + } + const addons = Object.fromEntries(Object.entries(parsed.addons).map(([id, addon]) => [id, parseAddon(addon, path, id)])); + return { ...copyRecord(parsed), addons }; +} + +function parseAddon(value: unknown, path: string, id: string): ExtensionUpdateAddon { + if (!isRecord(value) || !Array.isArray(value.updates)) { + throw new Error(`${path} add-on ${id} must contain an updates array.`); + } + return { + ...copyRecord(value), + updates: value.updates.map((update, index) => parseUpdate(update, path, id, index)), + }; +} + +function parseUpdate(value: unknown, path: string, id: string, index: number): ExtensionUpdate { + if (!isRecord(value) || typeof value.version !== "string" || typeof value.update_link !== "string") { + throw new Error(`${path} add-on ${id} update ${String(index)} must contain version and update_link strings.`); + } + return { + ...copyRecord(value), + version: value.version, + update_link: value.update_link, + }; +} + +function copyRecord(value: Readonly>): Record { + return Object.fromEntries(Object.entries(value)); +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNodeError(error: unknown, code: string): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === code; +} diff --git a/scripts/package-check.ts b/scripts/package-check.ts index 1e61a1f..dcc56ad 100644 --- a/scripts/package-check.ts +++ b/scripts/package-check.ts @@ -1,36 +1,15 @@ -import { createHash } from "node:crypto"; +import { lstat } from "node:fs/promises"; import { relative, resolve } from "node:path"; import rootPackage from "../package.json" with { type: "json" }; import { resolvePackagedBinary, type PlatformInput } from "@firefox-cli/native-host"; -import { packagedSignedExtensionProvenanceFile, readSignedExtensionProvenance } from "./extension-artifact-provenance.js"; -import { extensionManifestSchema, parseJsonManifestContent, packageManifestSchema } from "./manifest-validation.js"; -import { verifyExpectedExtensionManifest } from "./extension-manifest-check.js"; -import { readDevelopmentExtensionPayload, verifyExtensionBundlePayload, verifyPayloadMatchesDevelopmentBundle } from "./extension-payload-check.js"; -import { readOptionalRegularFileUnder, readRegularFileUnder } from "./safe-extension-files.js"; -import { verifySignedExtensionArtifactTrust } from "./signed-extension-artifact.js"; -import { packagedSignedExtensionXpiFile, type SignedExtensionChannel } from "./signed-extension-policy.js"; -import type { SignedExtensionSignatureVerifier } from "./signed-extension-signature.js"; -import { readZipArchive } from "./zip-archive.js"; - -const SIGNED_EXTENSION_REQUIRED_METADATA = ["META-INF/manifest.mf", "META-INF/mozilla.sf", "META-INF/mozilla.rsa"] as const; -const SIGNED_EXTENSION_OPTIONAL_COSE_METADATA = ["META-INF/cose.manifest", "META-INF/cose.sig"] as const; -const SIGNED_EXTENSION_DIGEST_HEADERS = [ - { algorithm: "sha256", headers: ["sha256-digest", "sha-256-digest"] }, - { algorithm: "sha384", headers: ["sha384-digest", "sha-384-digest"] }, - { algorithm: "sha512", headers: ["sha512-digest", "sha-512-digest"] }, -] as const; -const SIGNED_EXTENSION_MANIFEST_DIGEST_HEADERS = [ - { algorithm: "sha256", headers: ["sha256-digest-manifest", "sha-256-digest-manifest"] }, - { algorithm: "sha384", headers: ["sha384-digest-manifest", "sha-384-digest-manifest"] }, - { algorithm: "sha512", headers: ["sha512-digest-manifest", "sha-512-digest-manifest"] }, -] as const; +import { parseJsonManifestContent, packageManifestSchema } from "./manifest-validation.js"; +import { readRegularFileUnder } from "./safe-extension-files.js"; +import { packagedSignedExtensionXpiFile } from "./signed-extension-policy.js"; export interface PackageCheckOptions { readonly packageRoot: string; readonly platform?: PlatformInput; readonly requireSignedXpi?: boolean; - readonly signedExtensionChannel?: SignedExtensionChannel; - readonly signedExtensionSignatureVerifier?: SignedExtensionSignatureVerifier; } export async function verifyPackageLayout(options: PackageCheckOptions): Promise { @@ -40,11 +19,32 @@ export async function verifyPackageLayout(options: PackageCheckOptions): Promise await verifyPackageJson(options.packageRoot); const binaryPath = await resolvePackagedBinary(options.packageRoot, options.platform); await readRegularFileUnder(options.packageRoot, relative(options.packageRoot, binaryPath), "platform binary"); - await verifyExtensionArtifact(options); + await verifyAbsentPackagePath(options.packageRoot, "extension", "Package must not contain embedded extension artifacts under extension/"); + await verifyAbsentPackagePath(options.packageRoot, "docs/firefox-cli/updates.json", "Package must not contain a copied extension update manifest"); + if (options.requireSignedXpi) { + throw new Error(`Signed extension XPIs are downloadable release artifacts and must not be packaged at extension/${packagedSignedExtensionXpiFile}`); + } return artifacts; } +async function verifyAbsentPackagePath(packageRoot: string, relativePath: string, message: string): Promise { + const extensionPath = resolve(packageRoot, relativePath); + try { + await lstat(extensionPath); + } catch (error) { + if (isNodeError(error, "ENOENT")) { + return; + } + throw error; + } + throw new Error(message); +} + +function isNodeError(error: unknown, code: string): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === code; +} + async function verifyPackageJson(packageRoot: string): Promise { const packageJson = parseJsonManifestContent( (await readRegularFileUnder(packageRoot, "package.json", "package manifest")).toString("utf8"), @@ -64,248 +64,6 @@ async function verifyPackageJson(packageRoot: string): Promise { } } -async function verifyExtensionArtifact(options: PackageCheckOptions): Promise { - const signedXpiPath = resolve(options.packageRoot, `extension/${packagedSignedExtensionXpiFile}`); - const signedXpi = await readOptionalRegularFileUnder(options.packageRoot, `extension/${packagedSignedExtensionXpiFile}`, "signed extension XPI"); - - if (signedXpi !== undefined) { - await verifySignedExtensionArtifact({ - packageRoot: options.packageRoot, - artifactPath: signedXpiPath, - archiveData: signedXpi, - ...(options.signedExtensionChannel === undefined ? {} : { expectedChannel: options.signedExtensionChannel }), - ...(options.signedExtensionSignatureVerifier === undefined ? {} : { verifySignature: options.signedExtensionSignatureVerifier }), - }); - return; - } - - if (options.requireSignedXpi) { - throw new Error(`Expected signed extension XPI at ${signedXpiPath}`); - } - - const developmentPayload = await readDevelopmentExtensionPayload(options.packageRoot); - const manifest = parseJsonManifestContent( - (await readRegularFileUnder(options.packageRoot, "extension/development/manifest.json", "development extension manifest")).toString("utf8"), - "development extension manifest", - resolve(options.packageRoot, "extension/development/manifest.json"), - extensionManifestSchema, - ); - verifyExpectedExtensionManifest(manifest); - await verifyExtensionBundlePayload(developmentPayload); -} - -async function verifySignedExtensionArtifact(input: { - readonly packageRoot: string; - readonly artifactPath: string; - readonly archiveData: Buffer; - readonly expectedChannel?: SignedExtensionChannel; - readonly verifySignature?: SignedExtensionSignatureVerifier; -}): Promise { - const archive = readZipArchive(input.archiveData); - const signatureEntries = new Map(); - const xpiPayload = new Map(); - - for (const entry of archive.entries) { - if (entry.isDirectory) { - continue; - } - - const data = archive.readEntry(entry); - if (entry.name.startsWith("META-INF/")) { - signatureEntries.set(entry.name, data); - } else { - xpiPayload.set(entry.name, data); - } - } - - verifySignedExtensionMetadata(signatureEntries); - verifySignedExtensionDigests(signatureEntries, xpiPayload); - const provenancePath = resolve(input.packageRoot, "extension", packagedSignedExtensionProvenanceFile); - const provenance = await readSignedExtensionProvenance(provenancePath).catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Expected signed extension provenance at ${provenancePath}: ${message}`); - }); - await verifySignedExtensionArtifactTrust({ - artifactPath: input.artifactPath, - provenance, - signatureEntries, - xpiPayload, - ...(input.expectedChannel === undefined ? {} : { expectedChannel: input.expectedChannel }), - ...(input.verifySignature === undefined ? {} : { verifySignature: input.verifySignature }), - }); - - const manifestData = xpiPayload.get("manifest.json"); - if (manifestData === undefined) { - throw new Error(`Expected signed extension manifest in ${input.artifactPath}`); - } - const manifest = parseJsonManifestContent( - manifestData.toString("utf8"), - "signed extension manifest", - `${input.artifactPath}!/manifest.json`, - extensionManifestSchema, - ); - - verifyExpectedExtensionManifest(manifest); - await verifyExtensionBundlePayload(xpiPayload); - await verifyPayloadMatchesDevelopmentBundle(input.packageRoot, xpiPayload); -} - -function verifySignedExtensionMetadata(entries: ReadonlyMap): void { - const allowedEntries: ReadonlySet = new Set([...SIGNED_EXTENSION_REQUIRED_METADATA, ...SIGNED_EXTENSION_OPTIONAL_COSE_METADATA]); - if (entries.size === 0) { - throw new Error("Expected signed extension XPI signature metadata under META-INF"); - } - - const unexpectedEntries = [...entries.keys()].filter((entry) => !allowedEntries.has(entry)); - if (unexpectedEntries.length > 0) { - throw new Error(`Unexpected signed extension metadata: ${unexpectedEntries.join(", ")}`); - } - - for (const entry of SIGNED_EXTENSION_REQUIRED_METADATA) { - const data = entries.get(entry); - if (data === undefined) { - throw new Error(`Expected signed extension metadata entry: ${entry}`); - } - if (data.length === 0) { - throw new Error(`Expected non-empty signed extension metadata entry: ${entry}`); - } - } - - const coseManifest = entries.get("META-INF/cose.manifest"); - const coseSignature = entries.get("META-INF/cose.sig"); - if ((coseManifest === undefined) !== (coseSignature === undefined)) { - throw new Error("Expected COSE signed extension metadata entries to be present as a pair"); - } - for (const entry of SIGNED_EXTENSION_OPTIONAL_COSE_METADATA) { - const data = entries.get(entry); - if (data?.length === 0) { - throw new Error(`Expected non-empty signed extension metadata entry: ${entry}`); - } - } -} - -function verifySignedExtensionDigests(signatureEntries: ReadonlyMap, xpiPayload: ReadonlyMap): void { - const manifestFile = getSignatureEntry(signatureEntries, "META-INF/manifest.mf"); - const signatureFile = getSignatureEntry(signatureEntries, "META-INF/mozilla.sf"); - const rsaSignature = getSignatureEntry(signatureEntries, "META-INF/mozilla.rsa"); - if (rsaSignature[0] !== 0x30) { - throw new Error("Expected signed extension PKCS7 metadata entry: META-INF/mozilla.rsa"); - } - - const signableEntries = new Map(xpiPayload); - for (const entry of SIGNED_EXTENSION_OPTIONAL_COSE_METADATA) { - const data = signatureEntries.get(entry); - if (data !== undefined) { - signableEntries.set(entry, data); - } - } - - const manifestSections = parseJarManifest(manifestFile, "META-INF/manifest.mf"); - const manifestEntries = new Map>(); - for (const section of manifestSections) { - const name = section.get("name"); - if (name === undefined) { - continue; - } - if (manifestEntries.has(name)) { - throw new Error(`Duplicate signed extension manifest digest entry: ${name}`); - } - manifestEntries.set(name, section); - } - - for (const [file, data] of signableEntries) { - const section = manifestEntries.get(file); - if (section === undefined) { - throw new Error(`Expected signed extension digest for package file: ${file}`); - } - verifyDigestHeader(section, data, file, SIGNED_EXTENSION_DIGEST_HEADERS); - } - - const unexpectedDigestEntries = [...manifestEntries.keys()].filter((file) => !signableEntries.has(file)); - if (unexpectedDigestEntries.length > 0) { - throw new Error(`Signed extension metadata contains digest entries outside the package payload: ${unexpectedDigestEntries.join(", ")}`); - } - - const signatureMainSection = parseJarManifest(signatureFile, "META-INF/mozilla.sf")[0]; - if (signatureMainSection === undefined) { - throw new Error("Expected signed extension signature file metadata"); - } - verifyDigestHeader(signatureMainSection, manifestFile, "META-INF/manifest.mf", SIGNED_EXTENSION_MANIFEST_DIGEST_HEADERS); -} - -function getSignatureEntry(entries: ReadonlyMap, name: (typeof SIGNED_EXTENSION_REQUIRED_METADATA)[number]): Buffer { - const entry = entries.get(name); - if (entry === undefined) { - throw new Error(`Expected signed extension metadata entry: ${name}`); - } - return entry; -} - -function parseJarManifest(data: Buffer, label: string): readonly ReadonlyMap[] { - const sections: Map[] = []; - let currentSection = new Map(); - let currentKey: string | undefined; - - for (const line of data.toString("utf8").replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n")) { - if (line.length === 0) { - if (currentSection.size > 0) { - sections.push(currentSection); - } - currentSection = new Map(); - currentKey = undefined; - continue; - } - if (line.startsWith(" ")) { - if (currentKey === undefined) { - throw new Error(`Invalid signed extension manifest metadata in ${label}`); - } - currentSection.set(currentKey, `${currentSection.get(currentKey) ?? ""}${line.slice(1)}`); - continue; - } - - const separatorIndex = line.indexOf(":"); - if (separatorIndex <= 0) { - throw new Error(`Invalid signed extension manifest metadata in ${label}`); - } - const key = line.slice(0, separatorIndex).toLowerCase(); - if (currentSection.has(key)) { - throw new Error(`Duplicate signed extension manifest metadata header in ${label}: ${key}`); - } - const rawValue = line.slice(separatorIndex + 1); - currentSection.set(key, rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue); - currentKey = key; - } - - if (currentSection.size > 0) { - sections.push(currentSection); - } - - return sections; -} - -function verifyDigestHeader( - section: ReadonlyMap, - data: Buffer, - label: string, - digestHeaders: typeof SIGNED_EXTENSION_DIGEST_HEADERS | typeof SIGNED_EXTENSION_MANIFEST_DIGEST_HEADERS, -): void { - for (const { algorithm, headers } of digestHeaders) { - for (const header of headers) { - const expectedDigest = section.get(header); - if (expectedDigest === undefined) { - continue; - } - const actualDigest = createHash(algorithm).update(data).digest("base64"); - if (actualDigest !== expectedDigest) { - throw new Error(`Signed extension digest mismatch for ${label}`); - } - return; - } - } - - throw new Error(`Expected signed extension SHA-256 digest for ${label}`); -} - if (import.meta.main) { verifyPackageLayout({ packageRoot: resolve("dist/package") }) .then(() => { diff --git a/scripts/package.ts b/scripts/package.ts index 455cdda..62910d8 100644 --- a/scripts/package.ts +++ b/scripts/package.ts @@ -1,12 +1,9 @@ -import { chmod, cp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { chmod, cp, mkdir, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { getBinaryName, getPlatformKey } from "@firefox-cli/native-host"; import rootPackage from "../package.json" with { type: "json" }; -import { packagedSignedExtensionProvenanceFile, signedExtensionProvenanceArtifactName } from "./extension-artifact-provenance.js"; import { resetGeneratedArtifact } from "./generated-artifacts.js"; -import { packagedSignedExtensionProvenanceJson, readValidatedSignedExtensionSource, SignedExtensionSourceNotFoundError } from "./package-signed-extension.js"; import { copyPackagedBinary } from "./packaged-binary.js"; -import { packagedSignedExtensionXpiFile } from "./signed-extension-policy.js"; const packageRoot = resolve("dist/package"); const platformKey = getPlatformKey(); @@ -15,7 +12,6 @@ const binaryName = getBinaryName(); await resetGeneratedPackage(packageRoot); await mkdir(resolve(packageRoot, "bin", platformKey), { recursive: true }); -await mkdir(resolve(packageRoot, "extension/development"), { recursive: true }); await writePackageJson(packageRoot); await cp("README.md", resolve(packageRoot, "README.md")); @@ -27,13 +23,6 @@ await chmod(resolve(packageRoot, "bin/firefox-cli.js"), 0o755); await copyPackagedBinary({ sourcePath: resolve("dist/bin", platformKey, binaryName), packageRoot, platformKey, binaryName }); -await cp("dist/extension", resolve(packageRoot, "extension/development"), { - recursive: true, -}); - -await copyExtensionArchive(packageRoot); -await copySignedExtensionXpi(packageRoot); - console.log(`Assembled package at ${packageRoot}`); async function resetGeneratedPackage(path: string): Promise { @@ -49,58 +38,9 @@ async function writePackageJson(path: string): Promise { bin: { "firefox-cli": "./bin/firefox-cli.js", }, - files: ["bin", "lib", "extension", "README.md", "LICENSE"], + files: ["bin", "lib", "README.md", "LICENSE"], license: "AGPL-3.0-only", }; await writeFile(resolve(path, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`); } - -async function copyExtensionArchive(path: string): Promise { - const artifactName = `firefox-cli-${rootPackage.version}.zip`; - const artifactPath = resolve("dist/extension-artifacts", artifactName); - - try { - await cp(artifactPath, resolve(path, "extension/development", artifactName)); - return; - } catch { - // The development directory is still useful for local extension loading. - } - - const manifest = await readFile(resolve(path, "extension/development/manifest.json"), "utf8"); - await writeFile(resolve(path, "extension/development/README.md"), `Development extension directory only. Manifest:\n\n${manifest}`); -} - -async function copySignedExtensionXpi(path: string): Promise { - const envPath = process.env.FIREFOX_CLI_SIGNED_XPI; - const sourcePath = - envPath === undefined || envPath.length === 0 ? resolve("dist/extension-artifacts", `firefox-cli-${rootPackage.version}.xpi`) : resolve(envPath); - - try { - const source = await readValidatedSignedExtensionSource({ - sourceXpiPath: sourcePath, - provenancePath: signedExtensionProvenanceSourcePath(sourcePath), - }); - await writeFile(resolve(path, "extension", packagedSignedExtensionXpiFile), source.xpiData); - await writeFile(resolve(path, "extension", packagedSignedExtensionProvenanceFile), packagedSignedExtensionProvenanceJson(source.provenance)); - } catch (error) { - if ((envPath === undefined || envPath.length === 0) && error instanceof SignedExtensionSourceNotFoundError) { - return; - } - if (envPath !== undefined && envPath.length > 0) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to copy FIREFOX_CLI_SIGNED_XPI from ${sourcePath}: ${message}`); - } - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to copy signed extension XPI from ${sourcePath}: ${message}`); - } -} - -function signedExtensionProvenanceSourcePath(signedXpiPath: string): string { - const envPath = process.env.FIREFOX_CLI_SIGNED_XPI_PROVENANCE; - const defaultPath = - process.env.FIREFOX_CLI_SIGNED_XPI === undefined || process.env.FIREFOX_CLI_SIGNED_XPI.length === 0 - ? resolve("dist/extension-artifacts", signedExtensionProvenanceArtifactName(rootPackage.version)) - : `${signedXpiPath}.provenance.json`; - return envPath === undefined || envPath.length === 0 ? defaultPath : resolve(envPath); -} diff --git a/scripts/prepare-release-version.ts b/scripts/prepare-release-version.ts index c349108..e651719 100644 --- a/scripts/prepare-release-version.ts +++ b/scripts/prepare-release-version.ts @@ -1,6 +1,7 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; +import { syncExtensionUpdateManifest } from "./extension-update-manifest.js"; import { runProcess } from "./process-runner.js"; import { syncVersion } from "./sync-version.js"; @@ -33,6 +34,7 @@ export async function prepareReleaseVersion( } changedFiles.push(...(await syncVersion({ root }))); + changedFiles.push(...(await syncExtensionUpdateManifest({ root, version }))); return { previousVersion, version, diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 54bdea0..7a21625 100644 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -3,10 +3,16 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { z } from "zod"; import { FIREFOX_CLI_EXTENSION_ID, NATIVE_HOST_NAME, resolvePackagedBinary } from "@firefox-cli/native-host"; -import { parseJsonWithSchema, runCliJson } from "./manifest-validation.js"; +import { verifyExtensionUpdateManifestEntry } from "./extension-update-manifest.js"; +import { verifyExpectedExtensionManifest } from "./extension-manifest-check.js"; +import { verifyExtensionBundlePayload, verifyPayloadMatchesExtensionSource } from "./extension-payload-check.js"; +import { extensionManifestSchema, parseJsonManifestContent, parseJsonWithSchema, runCliJson } from "./manifest-validation.js"; import { verifyPackageLayout } from "./package-check.js"; +import { readValidatedSignedExtensionSource } from "./package-signed-extension.js"; import { runProcess } from "./process-runner.js"; import { resolveReleaseSignedXpiPolicy } from "./release-policy.js"; +import { signedExtensionProvenanceArtifactName } from "./extension-artifact-provenance.js"; +import { readZipArchive } from "./zip-archive.js"; import rootPackage from "../package.json" with { type: "json" }; const packageRoot = resolve("dist/package"); @@ -47,7 +53,11 @@ const staleDoctorOutputSchema = z }) .loose(); -await runCheck("package layout", async () => verifyPackageLayout({ packageRoot, requireSignedXpi: releasePolicy.requireSignedXpi })); +await runCheck("package layout", async () => verifyPackageLayout({ packageRoot })); +if (releasePolicy.requireSignedXpi) { + await runCheck("extension update manifest", async () => verifyExtensionUpdateManifestEntry({ root: resolve("."), version: rootPackage.version })); + await runCheck("signed extension release artifact", verifySignedExtensionReleaseArtifact); +} await runCheck("temp install --version", async () => { const installRoot = await createTempInstall(packageRoot); const result = await runNodeLauncher(installRoot, ["--version"]); @@ -102,6 +112,39 @@ async function runCheck(name: string, check: () => Promise): Promise { + const sourceXpiPath = resolve("dist/extension-artifacts", `firefox-cli-${rootPackage.version}.xpi`); + const source = await readValidatedSignedExtensionSource({ + sourceXpiPath, + provenancePath: resolve("dist/extension-artifacts", signedExtensionProvenanceArtifactName(rootPackage.version)), + }); + const xpiPayload = readXpiPayload(source.xpiData); + await verifyExtensionBundlePayload(xpiPayload); + await verifyPayloadMatchesExtensionSource(resolve("dist/extension"), xpiPayload); + const manifestData = xpiPayload.get("manifest.json"); + if (manifestData === undefined) { + throw new Error(`Expected signed extension manifest in ${source.sourceXpiPath}`); + } + const manifest = parseJsonManifestContent( + manifestData.toString("utf8"), + "signed extension manifest", + `${source.sourceXpiPath}!/manifest.json`, + extensionManifestSchema, + ); + verifyExpectedExtensionManifest(manifest); +} + +function readXpiPayload(xpiData: Buffer): ReadonlyMap { + const archive = readZipArchive(xpiData); + const payload = new Map(); + for (const entry of archive.entries) { + if (!entry.isDirectory && !entry.name.startsWith("META-INF/")) { + payload.set(entry.name, archive.readEntry(entry)); + } + } + return payload; +} + async function createTempInstall(sourcePackageRoot: string): Promise { const tempRoot = await mkdtemp(join(tmpdir(), "firefox-cli-release-check-")); const installRoot = join(tempRoot, "node_modules/firefox-cli"); diff --git a/scripts/test/package-check-test-utils.ts b/scripts/test/package-check-test-utils.ts index 1b876da..d910979 100644 --- a/scripts/test/package-check-test-utils.ts +++ b/scripts/test/package-check-test-utils.ts @@ -31,13 +31,14 @@ export const testSignatureVerifier: SignedExtensionSignatureVerifier = async (in return verifySignedExtensionSignatureWithMaterial(input, signingMaterial); }; -export async function createPackageRoot(options: { readonly includeBinary?: boolean; readonly extensionVersion?: string } = {}): Promise { +export async function createPackageRoot( + options: { readonly includeBinary?: boolean; readonly includeExtensionPayload?: boolean; readonly extensionVersion?: string } = {}, +): Promise { const packageRoot = await createTempDir("firefox-cli-package-check"); const platformKey = getPlatformKey(packageCheckPlatform); const extensionRequirements = getExtensionPermissionRequirements(); await mkdir(join(packageRoot, "bin", platformKey), { recursive: true }); - await mkdir(join(packageRoot, "extension/development"), { recursive: true }); await writeFile( join(packageRoot, "package.json"), @@ -59,36 +60,39 @@ export async function createPackageRoot(options: { readonly includeBinary?: bool await writeFile(join(packageRoot, "bin/firefox-cli.js"), "#!/usr/bin/env node\n"); await mkdir(join(packageRoot, "lib"), { recursive: true }); await writeFile(join(packageRoot, "lib/platform-binary.js"), "export {};\n"); - await writeFile( - join(packageRoot, "extension/development/manifest.json"), - `${JSON.stringify( - { - manifest_version: 3, - name: extensionDisplayMetadata.name, - version: options.extensionVersion ?? rootPackage.version, - description: extensionDisplayMetadata.description, - browser_specific_settings: { - gecko: { - id: "ff-cli-bridge@respawn.pro", - update_url: extensionDisplayMetadata.updateUrl, - strict_min_version: extensionRequirements.firefoxStrictMinVersion, - data_collection_permissions: extensionRequirements.dataCollection, + if (options.includeExtensionPayload === true) { + await mkdir(join(packageRoot, "extension/development"), { recursive: true }); + await writeFile( + join(packageRoot, "extension/development/manifest.json"), + `${JSON.stringify( + { + manifest_version: 3, + name: extensionDisplayMetadata.name, + version: options.extensionVersion ?? rootPackage.version, + description: extensionDisplayMetadata.description, + browser_specific_settings: { + gecko: { + id: "ff-cli-bridge@respawn.pro", + update_url: extensionDisplayMetadata.updateUrl, + strict_min_version: extensionRequirements.firefoxStrictMinVersion, + data_collection_permissions: extensionRequirements.dataCollection, + }, }, + background: { scripts: ["background.js"] }, + permissions: extensionRequirements.manifestPermissions, + host_permissions: extensionRequirements.hostPermissions, + action: { default_popup: "popup.html", default_title: extensionDisplayMetadata.actionTitle }, }, - background: { scripts: ["background.js"] }, - permissions: extensionRequirements.manifestPermissions, - host_permissions: extensionRequirements.hostPermissions, - action: { default_popup: "popup.html", default_title: extensionDisplayMetadata.actionTitle }, - }, - null, - 2, - )}\n`, - ); - await writeFile(join(packageRoot, "extension/development/background.js"), "console.log('bg');\n"); - await writeFile(join(packageRoot, "extension/development/content.js"), "console.log('cs');\n"); - await writeFile(join(packageRoot, "extension/development/popup.js"), "console.log('popup');\n"); - await writeFile(join(packageRoot, "extension/development/popup.html"), "\n"); - await writeFile(join(packageRoot, "extension/development/popup.css"), "body {}\n"); + null, + 2, + )}\n`, + ); + await writeFile(join(packageRoot, "extension/development/background.js"), "console.log('bg');\n"); + await writeFile(join(packageRoot, "extension/development/content.js"), "console.log('cs');\n"); + await writeFile(join(packageRoot, "extension/development/popup.js"), "console.log('popup');\n"); + await writeFile(join(packageRoot, "extension/development/popup.html"), "\n"); + await writeFile(join(packageRoot, "extension/development/popup.css"), "body {}\n"); + } if (options.includeBinary !== false) { await writeFile(join(packageRoot, "bin", platformKey, getBinaryName(packageCheckPlatform)), ""); diff --git a/scripts/test/package-check.test.ts b/scripts/test/package-check.test.ts index 2f21f2f..aa9207c 100644 --- a/scripts/test/package-check.test.ts +++ b/scripts/test/package-check.test.ts @@ -1,28 +1,14 @@ import { mkdir, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createTempDir } from "@firefox-cli/test-support"; -import { beforeAll, describe, expect, it } from "vitest"; -import rootPackage from "../../package.json" with { type: "json" }; -import { hashDirectoryPayload } from "../extension-artifact-provenance.js"; -import { extensionDisplayMetadata } from "../extension-display-metadata.js"; +import { describe, expect, it } from "vitest"; import { verifyPackageLayout } from "../package-check.js"; -import { - createPackageCheckOptions, - createPackageRoot, - initializePackageCheckSigningMaterial, - packageCheckPlatform, - testSignatureVerifier, - writeMatchingXpi, -} from "./package-check-test-utils.js"; +import { createPackageRoot, packageCheckPlatform } from "./package-check-test-utils.js"; const platform = packageCheckPlatform; -beforeAll(async () => { - await initializePackageCheckSigningMaterial(); -}, 30_000); - describe("verifyPackageLayout", () => { - it("accepts a complete development package layout", async () => { + it("accepts a complete package layout without embedded extension artifacts", async () => { const packageRoot = await createPackageRoot(); await verifyPackageLayout({ packageRoot, platform }); }); @@ -32,197 +18,24 @@ describe("verifyPackageLayout", () => { await expect(verifyPackageLayout({ packageRoot, platform })).rejects.toThrow(); }); - it("requires signed XPI for release checks", async () => { - const packageRoot = await createPackageRoot(); - await expect(verifyPackageLayout({ packageRoot, platform, requireSignedXpi: true })).rejects.toThrow("Expected signed extension XPI"); - }); - - it("accepts a matching signed XPI with deflated data, data descriptors, and EOCD comments", async () => { - const packageRoot = await createPackageRoot(); - await writeMatchingXpi(packageRoot, { - compressionMethod: 8, - eocdComment: "release candidate", - realSignature: true, - signed: true, - useDataDescriptor: true, - }); - - await verifyPackageLayout({ - packageRoot, - platform, - requireSignedXpi: true, - signedExtensionSignatureVerifier: testSignatureVerifier, - }); - }); - - it("requires signed XPI provenance for release checks", async () => { - const packageRoot = await createPackageRoot(); - await writeMatchingXpi(packageRoot, { writeProvenance: false }); - - await expect(verifyPackageLayout(createPackageCheckOptions(packageRoot, true))).rejects.toThrow("Expected signed extension provenance"); - }); - - it("rejects signed XPI provenance that does not match the packaged XPI", async () => { - const packageRoot = await createPackageRoot(); - await writeMatchingXpi(packageRoot, { - provenanceOverrides: { xpiSha256: "0".repeat(64) }, - }); - - await expect(verifyPackageLayout(createPackageCheckOptions(packageRoot, true))).rejects.toThrow("provenance digest"); - }); - - it("rejects signed XPI provenance with the wrong packaged XPI name", async () => { - const packageRoot = await createPackageRoot(); - await writeMatchingXpi(packageRoot, { - provenanceOverrides: { xpiFile: `firefox-cli-${rootPackage.version}.xpi` }, - }); - - await expect(verifyPackageLayout(createPackageCheckOptions(packageRoot, true))).rejects.toThrow("provenance XPI file"); - }); - - it("rejects signed XPI provenance with the wrong signing channel", async () => { + it("does not satisfy signed-XPI release checks from package contents", async () => { const packageRoot = await createPackageRoot(); - await writeMatchingXpi(packageRoot, { - provenanceOverrides: { channel: "listed" }, - }); - - await expect(verifyPackageLayout(createPackageCheckOptions(packageRoot, true))).rejects.toThrow("provenance channel"); - }); - - it("rejects renamed unsigned ZIPs for signed release checks", async () => { - const packageRoot = await createPackageRoot(); - await writeMatchingXpi(packageRoot, { signed: false }); - - await expect(verifyPackageLayout(createPackageCheckOptions(packageRoot, true))).rejects.toThrow("Expected signed extension XPI signature metadata"); - }); - - it("rejects renamed unsigned ZIPs when present in default package checks", async () => { - const packageRoot = await createPackageRoot(); - await writeMatchingXpi(packageRoot, { signed: false }); - - await expect(verifyPackageLayout(createPackageCheckOptions(packageRoot, false))).rejects.toThrow("Expected signed extension XPI signature metadata"); - }); - - it("runs real PKCS7 verification by default for present signed XPIs", async () => { - const packageRoot = await createPackageRoot(); - await writeMatchingXpi(packageRoot); - - await expect(verifyPackageLayout({ packageRoot, platform, requireSignedXpi: true })).rejects.toThrow("PKCS7 verification failed"); - }); - - it("rejects malformed present XPIs instead of falling back to the development extension", async () => { - const packageRoot = await createPackageRoot(); - await writeFile(join(packageRoot, "extension/firefox-cli.xpi"), "not a zip"); - - await expect(verifyPackageLayout(createPackageCheckOptions(packageRoot, false))).rejects.toThrow("missing end of central directory"); - }); - - it("rejects invalid signed-XPI metadata for signed release checks", async () => { - for (const signatureEntries of [ - { "META-INF/manifest.mf": "" }, - { - "META-INF/manifest.mf": "manifest", - "META-INF/mozilla.sf": "sf", - "META-INF/mozilla.rsa": "rsa", - "META-INF/unexpected.txt": "unexpected", - }, - { - "META-INF/manifest.mf": "manifest", - "META-INF/mozilla.sf": "sf", - "META-INF/mozilla.rsa": "rsa", - "META-INF/cose.manifest": "cose", - }, - ] as const) { - const packageRoot = await createPackageRoot(); - await writeMatchingXpi(packageRoot, { signatureEntries }); - - await expect(verifyPackageLayout(createPackageCheckOptions(packageRoot, true))).rejects.toThrow("signed extension metadata"); - } + await expect(verifyPackageLayout({ packageRoot, platform, requireSignedXpi: true })).rejects.toThrow( + "Signed extension XPIs are downloadable release artifacts", + ); }); - it("rejects signed-XPI digest metadata that does not match the payload", async () => { - const packageRoot = await createPackageRoot(); - await writeMatchingXpi(packageRoot, { - signatureEntries: { - "META-INF/manifest.mf": "Manifest-Version: 1.0\r\n\r\nName: manifest.json\r\nSHA256-Digest: invalid\r\n\r\n", - "META-INF/mozilla.sf": "Signature-Version: 1.0\r\nSHA256-Digest-Manifest: invalid\r\n\r\n", - "META-INF/mozilla.rsa": Buffer.from([0x30, 0x03, 0x02, 0x01, 0x00]), - }, - }); - - await expect(verifyPackageLayout(createPackageCheckOptions(packageRoot, true))).rejects.toThrow("digest"); + it("rejects embedded extension artifacts", async () => { + const packageRoot = await createPackageRoot({ includeExtensionPayload: true }); + await expect(verifyPackageLayout({ packageRoot, platform })).rejects.toThrow("Package must not contain embedded extension artifacts"); }); - for (const requireSignedXpi of [false, true] as const) { - const mode = requireSignedXpi ? "signed-release" : "default"; - - it(`rejects stale same-version XPI payloads in ${mode} mode`, async () => { - const packageRoot = await createPackageRoot(); - await writeMatchingXpi(packageRoot, { - payloadOverrides: { - "background.js": "console.log('stale but same version');\n", - }, - }); - - await expect(verifyPackageLayout(createPackageCheckOptions(packageRoot, requireSignedXpi))).rejects.toThrow("payload differs from package file"); - }); - - it(`rejects XPI path-set mismatches in ${mode} mode`, async () => { - const packageRoot = await createPackageRoot(); - await writeMatchingXpi(packageRoot, { - payloadOverrides: { - "unexpected.txt": "not part of the development payload", - }, - }); - - await expect(verifyPackageLayout(createPackageCheckOptions(packageRoot, requireSignedXpi))).rejects.toThrow("files outside the package payload"); - }); - - it(`rejects missing XPI payload files in ${mode} mode`, async () => { - const packageRoot = await createPackageRoot(); - await writeMatchingXpi(packageRoot, { - payloadOverrides: { - "popup.css": undefined, - }, - }); - - await expect(verifyPackageLayout(createPackageCheckOptions(packageRoot, requireSignedXpi))).rejects.toThrow("Expected extension artifact: popup.css"); - }); - - it(`rejects signed manifest drift in ${mode} mode`, async () => { - for (const manifestOverride of [ - { version: "9.9.9" }, - { browser_specific_settings: { gecko: { id: "wrong@example.invalid" } } }, - { browser_specific_settings: { gecko: { id: "ff-cli-bridge@respawn.pro" } } }, - { permissions: ["nativeMessaging", "scripting"] }, - { host_permissions: [] }, - { - browser_specific_settings: { - gecko: { - id: "ff-cli-bridge@respawn.pro", - update_url: extensionDisplayMetadata.updateUrl, - data_collection_permissions: { required: ["technicalAndInteraction"] }, - }, - }, - }, - ] as const) { - const packageRoot = await createPackageRoot(); - await writeMatchingXpi(packageRoot, { - manifestOverride, - }); - - await expect(verifyPackageLayout(createPackageCheckOptions(packageRoot, requireSignedXpi))).rejects.toThrow("Expected extension"); - } - }); - } - - it("ignores package-only development artifacts when comparing signed XPI payloads", async () => { + it("rejects copied extension update manifests", async () => { const packageRoot = await createPackageRoot(); - await writeFile(join(packageRoot, "extension/development/README.md"), "package notes\n"); - await writeFile(join(packageRoot, `extension/development/firefox-cli-${rootPackage.version}.zip`), "development archive placeholder"); - await writeMatchingXpi(packageRoot); + await mkdir(join(packageRoot, "docs/firefox-cli"), { recursive: true }); + await writeFile(join(packageRoot, "docs/firefox-cli/updates.json"), "{}\n"); - await verifyPackageLayout(createPackageCheckOptions(packageRoot, true)); + await expect(verifyPackageLayout({ packageRoot, platform })).rejects.toThrow("Package must not contain a copied extension update manifest"); }); it("rejects malformed and wrong-shape package manifests", async () => { @@ -235,71 +48,13 @@ describe("verifyPackageLayout", () => { await expect(verifyPackageLayout({ packageRoot: wrongShape, platform })).rejects.toThrow("Invalid package manifest"); }); - it("rejects malformed and wrong-shape development extension manifests", async () => { - const malformed = await createPackageRoot(); - await writeFile(join(malformed, "extension/development/manifest.json"), "{"); - await expect(verifyPackageLayout({ packageRoot: malformed, platform })).rejects.toThrow("Invalid development extension manifest JSON"); - - const wrongShape = await createPackageRoot(); - await writeFile( - join(wrongShape, "extension/development/manifest.json"), - JSON.stringify({ - manifest_version: 3, - name: extensionDisplayMetadata.name, - version: rootPackage.version, - background: { scripts: "background.js" }, - permissions: "scripting", - action: { default_popup: "popup.html" }, - }), - ); - await expect(verifyPackageLayout({ packageRoot: wrongShape, platform })).rejects.toThrow("Invalid development extension manifest"); - }); - - it("fails when packaged extension metadata drifts from the product version", async () => { - const packageRoot = await createPackageRoot({ extensionVersion: "9.9.9" }); - - await expect(verifyPackageLayout({ packageRoot, platform })).rejects.toThrow("Expected extension version"); - }); - - it("rejects extension bundles with shared JavaScript chunks", async () => { - const packageRoot = await createPackageRoot(); - await mkdir(join(packageRoot, "extension/development/chunks"), { recursive: true }); - await writeFile(join(packageRoot, "extension/development/chunks/index.js"), "export {};\n"); - - await expect(verifyPackageLayout({ packageRoot, platform })).rejects.toThrow("Unexpected extension JavaScript artifacts"); - }); - - it("rejects extension entry scripts that import generated chunks", async () => { - const packageRoot = await createPackageRoot(); - await writeFile(join(packageRoot, "extension/development/background.js"), 'import "./chunks/index.js";\n'); - - await expect(verifyPackageLayout({ packageRoot, platform })).rejects.toThrow("Expected standalone extension script"); - }); - - it("rejects symlinks in the packaged development extension payload", async () => { - const packageRoot = await createPackageRoot(); - const outsideFile = join(await createTempDir("firefox-cli-outside-extension"), "secret.txt"); + it("rejects symlinked packaged binaries before reading them", async () => { + const packageRoot = await createPackageRoot({ includeBinary: false }); + const outsideFile = join(await createTempDir("firefox-cli-outside-binary"), "firefox-cli"); await writeFile(outsideFile, "outside package\n"); - await symlink(outsideFile, join(packageRoot, "extension/development/outside.txt")); - - await expect(verifyPackageLayout({ packageRoot, platform })).rejects.toThrow("Refusing to traverse symlink"); - }); - - it("rejects symlinked signed extension artifacts before reading them", async () => { - const packageRoot = await createPackageRoot(); - const outsideFile = join(await createTempDir("firefox-cli-outside-xpi"), "firefox-cli.xpi"); - await writeFile(outsideFile, "not really an xpi\n"); - await symlink(outsideFile, join(packageRoot, "extension/firefox-cli.xpi")); + await mkdir(join(packageRoot, "bin/linux-x64"), { recursive: true }); + await symlink(outsideFile, join(packageRoot, "bin/linux-x64/firefox-cli")); await expect(verifyPackageLayout({ packageRoot, platform })).rejects.toThrow("Refusing to read symlink"); }); - - it("rejects symlinks when hashing extension source provenance", async () => { - const packageRoot = await createPackageRoot(); - const outsideFile = join(await createTempDir("firefox-cli-outside-provenance"), "secret.txt"); - await writeFile(outsideFile, "outside package\n"); - await symlink(outsideFile, join(packageRoot, "extension/development/outside.txt")); - - await expect(hashDirectoryPayload(join(packageRoot, "extension/development"))).rejects.toThrow("Refusing to traverse symlink"); - }); }); diff --git a/scripts/test/prepare-release-version.test.ts b/scripts/test/prepare-release-version.test.ts index a1a1efb..e5249be 100644 --- a/scripts/test/prepare-release-version.test.ts +++ b/scripts/test/prepare-release-version.test.ts @@ -3,6 +3,7 @@ import { dirname, join } from "node:path"; import { createTempDir } from "@firefox-cli/test-support"; import { describe, expect, it } from "vitest"; import { extensionDisplayMetadata } from "../extension-display-metadata.js"; +import { extensionReleaseXpiUrl, extensionUpdateManifestPath } from "../extension-update-manifest.js"; import { prepareReleaseVersion, selectReleaseVersion } from "../prepare-release-version.js"; describe("prepareReleaseVersion", () => { @@ -36,6 +37,7 @@ describe("prepareReleaseVersion", () => { "packages/extension/src/manifest.json", ".claude-plugin/plugin.json", "bun.lock", + "docs/firefox-cli/updates.json", ], }); await expect(readJsonVersion(join(root, "package.json"))).resolves.toBe("0.1.1"); @@ -43,6 +45,7 @@ describe("prepareReleaseVersion", () => { await expect(readJsonVersion(join(root, "packages/extension/src/manifest.json"))).resolves.toBe("0.1.1"); await expect(readJsonVersion(join(root, ".claude-plugin/plugin.json"))).resolves.toBe("0.1.1"); await expect(readFile(join(root, "bun.lock"), "utf8")).resolves.toContain('"version": "0.1.1"'); + await expect(readExtensionUpdateLink(join(root, extensionUpdateManifestPath), "0.1.1")).resolves.toBe(extensionReleaseXpiUrl("0.1.1")); }); it("accepts an explicit manual target version", async () => { @@ -57,6 +60,59 @@ describe("prepareReleaseVersion", () => { expect(result.version).toBe("0.2.0"); expect(result.tag).toBe("v0.2.0"); await expect(readJsonVersion(join(root, "package.json"))).resolves.toBe("0.2.0"); + await expect(readExtensionUpdateLink(join(root, extensionUpdateManifestPath), "0.2.0")).resolves.toBe(extensionReleaseXpiUrl("0.2.0")); + }); + + it("preserves unknown update manifest metadata during release version prep", async () => { + const root = await createReleaseFixture("0.1.0"); + const manifestPath = join(root, extensionUpdateManifestPath); + const manifest = await readJsonObject(manifestPath); + await writeFile( + manifestPath, + `${JSON.stringify( + { + ...manifest, + schema_version: 1, + addons: { + "ff-cli-bridge@respawn.pro": { + custom_addon_field: true, + updates: [ + { + version: "0.1.0", + update_link: extensionReleaseXpiUrl("0.1.0"), + update_hash: "sha256:old", + }, + ], + }, + }, + }, + null, + 2, + )}\n`, + ); + + await prepareReleaseVersion({ + root, + targetVersion: "0.1.0", + unavailableTags: [], + }); + + const updated = await readJsonObject(manifestPath); + expect(updated).toMatchObject({ + schema_version: 1, + addons: { + "ff-cli-bridge@respawn.pro": { + custom_addon_field: true, + updates: [ + { + version: "0.1.0", + update_link: extensionReleaseXpiUrl("0.1.0"), + update_hash: "sha256:old", + }, + ], + }, + }, + }); }); }); @@ -94,6 +150,32 @@ async function createReleaseFixture(version: string): Promise { " },", ].join("\n"), ); + const updateManifestFixturePath = join(root, extensionUpdateManifestPath); + await mkdir(dirname(updateManifestFixturePath), { recursive: true }); + await writeFile( + updateManifestFixturePath, + `${JSON.stringify( + { + addons: { + "ff-cli-bridge@respawn.pro": { + updates: [ + { + version, + update_link: extensionReleaseXpiUrl(version), + applications: { + gecko: { + strict_min_version: "150.0", + }, + }, + }, + ], + }, + }, + }, + null, + 2, + )}\n`, + ); return root; } @@ -113,6 +195,38 @@ async function writeVersionJson(path: string, name: string, version: string): Pr } async function readJsonVersion(path: string): Promise { + const parsed = await readJsonObject(path); + return "version" in parsed ? parsed.version : undefined; +} + +async function readJsonObject(path: string): Promise>> { + const parsed: unknown = JSON.parse(await readFile(path, "utf8")); + if (!isRecord(parsed)) { + throw new Error(`${path} must contain a JSON object.`); + } + return parsed; +} + +async function readExtensionUpdateLink(path: string, version: string): Promise { const parsed: unknown = JSON.parse(await readFile(path, "utf8")); - return parsed !== null && typeof parsed === "object" && "version" in parsed ? parsed.version : undefined; + if (!isRecord(parsed) || !isRecord(parsed.addons)) { + return undefined; + } + const addon = parsed.addons["ff-cli-bridge@respawn.pro"]; + if (!isRecord(addon)) { + return undefined; + } + if (!isUnknownArray(addon.updates)) { + return undefined; + } + const match = addon.updates.find((update) => isRecord(update) && update.version === version); + return isRecord(match) ? match.update_link : undefined; +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isUnknownArray(value: unknown): value is readonly unknown[] { + return Array.isArray(value); }