diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index 9a9eac7601..cce1795d18 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -24,6 +24,7 @@ import { resolveDesktopBuildVersion, resolveRuntimeHostSetupPackage, } from '../../scripts/desktop-nightly.mjs'; +import { stampMacAppExecutableUuids } from '../../scripts/macos-executable-uuid.mjs'; import { workspaceReleaseManifest } from '../../scripts/release-cli-file-policy.mjs'; import { resolveProductManifestIdentity } from '../../scripts/product-release-identity.mjs'; @@ -60,6 +61,16 @@ async function stageReleaseManifests({ packager }) { packager.config.files.push({ from: stage, to: 'node_modules/@maka' }); } +// Runs before signing, so the stamped executables are what gets signed. See +// scripts/macos-executable-uuid.mjs for why Electron's own LC_UUID cannot ship. +async function stampMacExecutableUuids({ electronPlatformName, appOutDir, packager }) { + if (electronPlatformName !== 'darwin') return; + await stampMacAppExecutableUuids( + join(appOutDir, `${packager.appInfo.productFilename}.app`), + packager.appInfo.id, + ); +} + const rootManifest = readManifest('../../package.json'); const { runtimeHostSetupPackage } = resolveProductManifestIdentity({ rootManifest, @@ -73,6 +84,7 @@ const baseDesktopBuilderConfig = { artifactName: 'Maka-${version}-mac-${arch}.${ext}', asar: true, beforePack: stageReleaseManifests, + afterPack: stampMacExecutableUuids, extraMetadata: { runtimeHostSetupPackage, makaUpdateChannel: 'release' }, directories: { output: 'release', diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index 008c91094d..fc634d32c7 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -17,9 +17,9 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t |---|---:| | windows-backend-gap | 27 | | portable-candidate | 60 | -| platform-contract | 49 | +| platform-contract | 50 | -Total Windows-excluded declarations: **136** +Total Windows-excluded declarations: **137** ## Inventory @@ -155,6 +155,7 @@ Total Windows-excluded declarations: **136** | platform-contract | `packages/storage/src/__tests__/workspace-identity.test.ts` an unreadable ancestor Git repository fails closed until permissions return | `process.platform === 'win32' ? 'POSIX permissions are required for an unreadable HEAD fixture' : false` | | portable-candidate | `packages/storage/src/__tests__/workspace-identity.test.ts` a dangling .git symlink in the workspace itself blocks marker publication | `process.platform === 'win32' ? 'Windows symlink creation requires elevated privileges or Developer Mode' : false` | | platform-contract | `packages/storage/src/__tests__/workspace-identity.test.ts` an unmarked read-only workspace fails without leaving marker state | `process.platform === 'win32' ? 'POSIX permissions are required to create a read-only workspace fixture' : false` | +| platform-contract | `scripts/macos-executable-uuid.test.mjs` rewrites the UUID of Electron's own executable | `process.platform !== 'darwin' && 'Electron ships a Mach-O executable only on macOS'` | | platform-contract | `scripts/macos-update-archive.test.mjs` the macOS update ZIP keeps bundle symlinks and modification times | `process.platform !== 'darwin' && 'the macOS update ZIP is built on macOS'` | | portable-candidate | `scripts/qualify-released-cli-state-root.test.mjs` starts the liveness window after a delayed Runtime Host Ready | `process.platform === 'win32'` | | portable-candidate | `scripts/qualify-released-cli-state-root.test.mjs` starts the liveness window after the real Runtime Host is ready | `process.platform === 'win32'` | diff --git a/package.json b/package.json index e8f75ffa41..14f1b76381 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "check:runtime-host-peer-dependencies": "node scripts/generate-runtime-host-peer-dependencies.mjs --check", "generate:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs", "check:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs --check", - "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test --test-concurrency=1 scripts/staple-macos-artifact.test.mjs scripts/product-nightly.test.mjs scripts/desktop-release-targets.test.mjs scripts/verify-linux-harness.test.mjs scripts/desktop-nightly.test.mjs scripts/desktop-nightly-stage.test.mjs scripts/desktop-nightly-release.test.mjs scripts/desktop-nightly-workflow-policy.test.mjs scripts/audit-shipped-dependencies.test.mjs scripts/product-release.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/qualify-released-cli-state-root.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/macos-update-archive.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/prepare-windows-upgrade-baseline.test.mjs scripts/windows-package-source-closure.test.mjs", + "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test --test-concurrency=1 scripts/staple-macos-artifact.test.mjs scripts/product-nightly.test.mjs scripts/desktop-release-targets.test.mjs scripts/verify-linux-harness.test.mjs scripts/desktop-nightly.test.mjs scripts/desktop-nightly-stage.test.mjs scripts/desktop-nightly-release.test.mjs scripts/desktop-nightly-workflow-policy.test.mjs scripts/audit-shipped-dependencies.test.mjs scripts/product-release.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/qualify-released-cli-state-root.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/macos-executable-uuid.test.mjs scripts/macos-update-archive.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/prepare-windows-upgrade-baseline.test.mjs scripts/windows-package-source-closure.test.mjs", "package:macos-arm64": "node scripts/package-macos.mjs arm64", "package:macos-x64": "node scripts/package-macos.mjs x64", "verify:macos": "node scripts/verify-macos-dmg.mjs", diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index 3276c4c409..cfc61bad9d 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -86,6 +86,8 @@ const RELEASE_CONTRACT_FILES = new Set([ 'scripts/product-nightly.test.mjs', 'scripts/verify-packaged-app.mjs', 'scripts/verify-packaged-app.test.mjs', + 'scripts/macos-executable-uuid.mjs', + 'scripts/macos-executable-uuid.test.mjs', 'scripts/macos-update-archive.test.mjs', 'scripts/verify-windows-autoupdate.mjs', 'scripts/verify-windows-installer-lifecycle.mjs', diff --git a/scripts/desktop-release-targets.test.mjs b/scripts/desktop-release-targets.test.mjs index 2d3b19a5f0..9d115bdd3b 100644 --- a/scripts/desktop-release-targets.test.mjs +++ b/scripts/desktop-release-targets.test.mjs @@ -192,12 +192,13 @@ const DESKTOP_PROJECT_DIRECTORY = fileURLToPath(new URL('../apps/desktop', impor * * Copied through JSON rather than `structuredClone`: everything electron-builder reads * here has to survive serialization anyway, and the structured clone algorithm rejected - * this object on Node 24 while accepting it on 26. `beforePack` is the one function in - * the configuration; it is carried across by reference and never invoked by this test. + * this object on Node 24 while accepting it on 26. `beforePack` and `afterPack` are the + * functions in the configuration; they are carried across by reference and never invoked + * by this test. */ function isolatedBuilderConfig() { - const { beforePack, ...rest } = resolveDesktopBuilderConfig({}); - return { ...JSON.parse(JSON.stringify(rest)), beforePack }; + const { beforePack, afterPack, ...rest } = resolveDesktopBuilderConfig({}); + return { ...JSON.parse(JSON.stringify(rest)), beforePack, afterPack }; } /** diff --git a/scripts/macos-executable-uuid.mjs b/scripts/macos-executable-uuid.mjs new file mode 100644 index 0000000000..656f4cff8e --- /dev/null +++ b/scripts/macos-executable-uuid.mjs @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// electron-builder ships Electron's prebuilt executables renamed, not relinked, +// so every app on the same Electron release carries the same Mach-O LC_UUID. +// macOS Local Network Privacy (NECP) identifies a program by its main +// executable's LC_UUID, so a shared UUID lets one app's grant or denial land on +// another (Apple TN3178, r. 30421029). The packaging hook stamps each executable +// with a UUID of its own before signing; the release verifier checks the stamp +// survived. + +import { createHash } from 'node:crypto'; +import { readFile, readdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +const MH_MAGIC_64 = 0xfeedfacf; +const FAT_MAGIC = 0xcafebabe; +const LC_UUID = 0x1b; +const MACH_HEADER_64_SIZE = 32; +const FAT_HEADER_SIZE = 8; +const FAT_ARCH_SIZE = 20; +const UUID_COMMAND_SIZE = 24; + +// Fixed namespace for the UUIDv5 names below. Changing it changes every +// shipped UUID, which macOS treats as a different program. +const MAKA_EXECUTABLE_UUID_NAMESPACE = Buffer.from('4195730630d44e84a842a14db6f67a8f', 'hex'); + +/** + * The UUID an executable ships with. Deterministic, so the release verifier can + * recompute it from the bundle alone; the app id keeps it distinct from every + * other Electron app and the CPU type keeps the arm64 and x64 builds apart. + */ +export function expectedExecutableUuid({ appId, executableName, cputype }) { + const digest = createHash('sha1') + .update(MAKA_EXECUTABLE_UUID_NAMESPACE) + .update(`${appId}/${executableName}/${cputype}`) + .digest(); + const uuid = digest.subarray(0, 16); + uuid[6] = (uuid[6] & 0x0f) | 0x50; + uuid[8] = (uuid[8] & 0x3f) | 0x80; + return formatUuid(uuid); +} + +function formatUuid(bytes) { + const hex = bytes.toString('hex').toUpperCase(); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +// Each slice as [start, end) within the file; a thin executable is one slice. +function slices(binary) { + if (binary.length < 4 || binary.readUInt32BE(0) !== FAT_MAGIC) { + return [{ start: 0, end: binary.length }]; + } + if (binary.length < FAT_HEADER_SIZE) throw new Error('truncated universal header'); + const count = binary.readUInt32BE(4); + if (count === 0) throw new Error('universal executable has no slices'); + if (FAT_HEADER_SIZE + count * FAT_ARCH_SIZE > binary.length) { + throw new Error('truncated universal header'); + } + return Array.from({ length: count }, (_, index) => { + const entry = FAT_HEADER_SIZE + index * FAT_ARCH_SIZE; + const start = binary.readUInt32BE(entry + 8); + const end = start + binary.readUInt32BE(entry + 12); + if (end > binary.length) throw new Error('universal slice extends past the file'); + return { start, end }; + }); +} + +function uuidCommandOffset(binary, { start, end }) { + if (end < start + MACH_HEADER_64_SIZE) throw new Error('truncated Mach-O header'); + if (binary.readUInt32LE(start) !== MH_MAGIC_64) { + throw new Error('not a 64-bit Mach-O executable'); + } + const cputype = binary.readUInt32LE(start + 4); + const commandCount = binary.readUInt32LE(start + 16); + const commandsEnd = start + MACH_HEADER_64_SIZE + binary.readUInt32LE(start + 20); + if (commandsEnd > end) throw new Error('Mach-O load commands extend past the slice'); + let cursor = start + MACH_HEADER_64_SIZE; + for (let index = 0; index < commandCount; index++) { + if (cursor + 8 > commandsEnd) throw new Error('malformed Mach-O load command'); + const command = binary.readUInt32LE(cursor); + const size = binary.readUInt32LE(cursor + 4); + if (size < 8 || cursor + size > commandsEnd) { + throw new Error('malformed Mach-O load command'); + } + if (command === LC_UUID) { + if (size !== UUID_COMMAND_SIZE) throw new Error('malformed LC_UUID load command'); + return { cputype, offset: cursor + 8 }; + } + cursor += size; + } + throw new Error('Mach-O executable has no LC_UUID load command'); +} + +/** Every slice's CPU type and LC_UUID, in file order. */ +export function machOUuids(binary) { + return slices(binary).map((slice) => { + const { cputype, offset } = uuidCommandOffset(binary, slice); + return { cputype, uuid: formatUuid(binary.subarray(offset, offset + 16)) }; + }); +} + +/** A copy of `binary` with each slice's LC_UUID replaced by its expected UUID. */ +export function stampMachOUuids(binary, { appId, executableName }) { + const stamped = Buffer.from(binary); + for (const slice of slices(stamped)) { + const { cputype, offset } = uuidCommandOffset(stamped, slice); + const uuid = expectedExecutableUuid({ appId, executableName, cputype }); + Buffer.from(uuid.replaceAll('-', ''), 'hex').copy(stamped, offset); + } + return stamped; +} + +/** + * The app's main executable and each helper app's, read from the bundle rather + * than from Electron's naming convention: one file in every `Contents/MacOS`. + */ +export async function macAppExecutables(appPath) { + const frameworks = join(appPath, 'Contents', 'Frameworks'); + const helpers = (await readdir(frameworks)).filter((name) => name.endsWith('.app')).sort(); + const bundles = [appPath, ...helpers.map((name) => join(frameworks, name))]; + return Promise.all( + bundles.map(async (bundle) => { + const directory = join(bundle, 'Contents', 'MacOS'); + const entries = await readdir(directory); + if (entries.length !== 1) { + throw new Error(`Expected one executable in ${directory}, found ${entries.length}.`); + } + return { name: entries[0], path: join(directory, entries[0]) }; + }), + ); +} + +export async function stampMacAppExecutableUuids(appPath, appId) { + for (const { name, path } of await macAppExecutables(appPath)) { + await writeFile(path, stampMachOUuids(await readFile(path), { appId, executableName: name })); + } +} + +export async function assertMacAppExecutableUuids(appPath, appId) { + const executables = await macAppExecutables(appPath); + if (executables.length < 2) { + throw new Error(`Expected Maka and its helper executables in ${appPath}.`); + } + for (const { name, path } of executables) { + for (const { cputype, uuid } of machOUuids(await readFile(path))) { + const expected = expectedExecutableUuid({ appId, executableName: name, cputype }); + if (uuid !== expected) { + throw new Error( + `${name} ships LC_UUID ${uuid}, expected ${expected}; the packaging hook did not stamp it.`, + ); + } + } + } +} diff --git a/scripts/macos-executable-uuid.test.mjs b/scripts/macos-executable-uuid.test.mjs new file mode 100644 index 0000000000..da3496a2f8 --- /dev/null +++ b/scripts/macos-executable-uuid.test.mjs @@ -0,0 +1,256 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { describe, test } from 'node:test'; +import { + assertMacAppExecutableUuids, + expectedExecutableUuid, + machOUuids, + stampMacAppExecutableUuids, + stampMachOUuids, +} from './macos-executable-uuid.mjs'; + +const CPU_TYPE_ARM64 = 0x0100000c; +const CPU_TYPE_X86_64 = 0x01000007; +// Electron 43.4.1's arm64 executable; any app that ships it renamed carries it too. +const STOCK_UUID = '4C4C4470-5555-3144-A124-518576DD11E3'; +const APP_ID = 'com.maka.desktop'; + +// A minimal 64-bit Mach-O slice: header, an unrelated load command, LC_UUID. +function machOSlice(cputype, uuid = STOCK_UUID) { + const header = Buffer.alloc(32); + header.writeUInt32LE(0xfeedfacf, 0); + header.writeUInt32LE(cputype, 4); + header.writeUInt32LE(2, 12); + header.writeUInt32LE(2, 16); + header.writeUInt32LE(40, 20); + const other = Buffer.alloc(16); + other.writeUInt32LE(0x32, 0); + other.writeUInt32LE(16, 4); + const uuidCommand = Buffer.alloc(24); + uuidCommand.writeUInt32LE(0x1b, 0); + uuidCommand.writeUInt32LE(24, 4); + Buffer.from(uuid.replaceAll('-', ''), 'hex').copy(uuidCommand, 8); + return Buffer.concat([header, other, uuidCommand]); +} + +function fatBinary(slices) { + const tableSize = 8 + slices.length * 20; + const table = Buffer.alloc(tableSize); + table.writeUInt32BE(0xcafebabe, 0); + table.writeUInt32BE(slices.length, 4); + let offset = tableSize; + slices.forEach((slice, index) => { + const entry = 8 + index * 20; + table.writeUInt32BE(slice.readUInt32LE(4), entry); + table.writeUInt32BE(offset, entry + 8); + table.writeUInt32BE(slice.length, entry + 12); + offset += slice.length; + }); + return Buffer.concat([table, ...slices]); +} + +describe('expectedExecutableUuid', () => { + const base = { appId: APP_ID, executableName: 'Maka', cputype: CPU_TYPE_ARM64 }; + + test('is stable across calls, so an update keeps the identity macOS saw', () => { + assert.equal(expectedExecutableUuid(base), expectedExecutableUuid({ ...base })); + }); + + test('differs by app id, executable and CPU type', () => { + const uuids = new Set([ + expectedExecutableUuid(base), + expectedExecutableUuid({ ...base, appId: 'com.example.other' }), + expectedExecutableUuid({ ...base, executableName: 'Maka Helper' }), + expectedExecutableUuid({ ...base, cputype: CPU_TYPE_X86_64 }), + ]); + assert.equal(uuids.size, 4); + }); + + // The namespace and name format are part of the shipped identity; a refactor + // that changes this value changes the UUID of every Maka executable. + test('keeps the shipped UUID of the arm64 main executable', () => { + assert.equal(expectedExecutableUuid(base), 'E08E3A84-68A1-5189-9663-A4FEC17A5EE5'); + }); + + test('is a well-formed version 5 UUID', () => { + assert.match( + expectedExecutableUuid(base), + /^[0-9A-F]{8}-[0-9A-F]{4}-5[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/, + ); + }); +}); + +describe('stampMachOUuids', () => { + test('replaces the stock UUID of a thin executable without touching the input', () => { + const original = machOSlice(CPU_TYPE_ARM64); + const stamped = stampMachOUuids(original, { appId: APP_ID, executableName: 'Maka' }); + + assert.deepEqual(machOUuids(original), [{ cputype: CPU_TYPE_ARM64, uuid: STOCK_UUID }]); + assert.deepEqual(machOUuids(stamped), [ + { + cputype: CPU_TYPE_ARM64, + uuid: expectedExecutableUuid({ + appId: APP_ID, + executableName: 'Maka', + cputype: CPU_TYPE_ARM64, + }), + }, + ]); + assert.equal(stamped.length, original.length); + }); + + test('is idempotent', () => { + const once = stampMachOUuids(machOSlice(CPU_TYPE_ARM64), { + appId: APP_ID, + executableName: 'Maka', + }); + assert.deepEqual(stampMachOUuids(once, { appId: APP_ID, executableName: 'Maka' }), once); + }); + + test('gives each slice of a universal executable its own UUID', () => { + const stamped = stampMachOUuids( + fatBinary([machOSlice(CPU_TYPE_X86_64), machOSlice(CPU_TYPE_ARM64)]), + { appId: APP_ID, executableName: 'Maka' }, + ); + const [x64, arm64] = machOUuids(stamped); + assert.equal(x64.cputype, CPU_TYPE_X86_64); + assert.equal(arm64.cputype, CPU_TYPE_ARM64); + assert.notEqual(x64.uuid, arm64.uuid); + assert.notEqual(x64.uuid, STOCK_UUID); + assert.notEqual(arm64.uuid, STOCK_UUID); + }); + + test('refuses a file that is not a 64-bit Mach-O executable', () => { + assert.throws( + () => + stampMachOUuids(Buffer.from('#!/bin/sh\necho hi\n'.padEnd(64)), { + appId: APP_ID, + executableName: 'Maka', + }), + /not a 64-bit Mach-O executable/, + ); + }); + + test('refuses a universal executable with no slices', () => { + assert.throws(() => machOUuids(Buffer.from('cafebabe00000000', 'hex')), /has no slices/); + }); + + test('refuses a universal slice that extends past the file', () => { + const fat = fatBinary([machOSlice(CPU_TYPE_ARM64)]); + fat.writeUInt32BE(fat.length, 8 + 12); + assert.throws(() => machOUuids(fat), /extends past the file/); + }); + + test('refuses an LC_UUID command of the wrong size', () => { + const slice = machOSlice(CPU_TYPE_ARM64); + slice.writeUInt32LE(8, 48 + 4); + assert.throws( + () => stampMachOUuids(slice, { appId: APP_ID, executableName: 'Maka' }), + /malformed LC_UUID/, + ); + }); + + test('refuses load commands that extend past the slice', () => { + const slice = machOSlice(CPU_TYPE_ARM64); + slice.writeUInt32LE(4096, 20); + assert.throws(() => machOUuids(slice), /extend past the slice/); + }); + + test('refuses an executable without an LC_UUID load command', () => { + const slice = machOSlice(CPU_TYPE_ARM64); + slice.writeUInt32LE(1, 16); + assert.throws(() => machOUuids(slice), /no LC_UUID load command/); + }); + + test("rewrites the UUID of Electron's own executable", { + skip: process.platform !== 'darwin' && 'Electron ships a Mach-O executable only on macOS', + }, async () => { + const require = createRequire(new URL('../apps/desktop/package.json', import.meta.url)); + const executable = join( + dirname(require.resolve('electron/package.json')), + 'dist/Electron.app/Contents/MacOS/Electron', + ); + const original = await readFile(executable); + const stamped = stampMachOUuids(original, { appId: APP_ID, executableName: 'Maka' }); + const before = machOUuids(original); + const after = machOUuids(stamped); + + assert.equal(after.length, before.length); + for (const [index, { cputype, uuid }] of after.entries()) { + assert.notEqual(uuid, before[index].uuid); + assert.equal( + uuid, + expectedExecutableUuid({ appId: APP_ID, executableName: 'Maka', cputype }), + ); + } + }); +}); + +describe('Maka.app executables', () => { + const executables = ['Maka', 'Maka Helper', 'Maka Helper (GPU)', 'Maka Helper (Renderer)']; + + async function makeApp(t) { + const root = await mkdtemp(join(tmpdir(), 'maka-uuid-')); + t.after(() => rm(root, { recursive: true, force: true })); + const app = join(root, 'Maka.app'); + for (const name of executables) { + const bundle = name === 'Maka' ? app : join(app, 'Contents', 'Frameworks', `${name}.app`); + await mkdir(join(bundle, 'Contents', 'MacOS'), { recursive: true }); + await writeFile(join(bundle, 'Contents', 'MacOS', name), machOSlice(CPU_TYPE_ARM64)); + } + // A framework is not a helper app and is left alone. + await mkdir(join(app, 'Contents', 'Frameworks', 'Electron Framework.framework')); + return app; + } + + test('the release check rejects executables that still carry the stock UUID', async (t) => { + const app = await makeApp(t); + await assert.rejects( + assertMacAppExecutableUuids(app, APP_ID), + new RegExp(`Maka ships LC_UUID ${STOCK_UUID}`), + ); + }); + + test('stamping gives every executable a distinct UUID the release check accepts', async (t) => { + const app = await makeApp(t); + await stampMacAppExecutableUuids(app, APP_ID); + await assertMacAppExecutableUuids(app, APP_ID); + + const uuids = await Promise.all( + executables.map(async (name) => { + const bundle = name === 'Maka' ? app : join(app, 'Contents', 'Frameworks', `${name}.app`); + return machOUuids(await readFile(join(bundle, 'Contents', 'MacOS', name)))[0].uuid; + }), + ); + assert.equal(new Set(uuids).size, executables.length); + assert.ok(!uuids.includes(STOCK_UUID)); + }); + + test('the release check rejects an app stamped for another app id', async (t) => { + const app = await makeApp(t); + await stampMacAppExecutableUuids(app, 'com.example.other'); + await assert.rejects(assertMacAppExecutableUuids(app, APP_ID), /did not stamp it/); + }); +}); diff --git a/scripts/verify-macos-dmg.mjs b/scripts/verify-macos-dmg.mjs index bd801d9235..1249423b03 100644 --- a/scripts/verify-macos-dmg.mjs +++ b/scripts/verify-macos-dmg.mjs @@ -32,6 +32,7 @@ import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { crc32, inflateSync } from 'node:zlib'; import { FILESYSTEM_WORKER_PROTOCOL_VERSION } from '../packages/runtime/dist/filesystem-worker/protocol.js'; +import { assertMacAppExecutableUuids } from './macos-executable-uuid.mjs'; import { readProductManifestIdentity } from './product-release-identity.mjs'; import { assertPackagedUpdateConfiguration } from './desktop-update-contract.mjs'; import { stapleMacosArtifact } from './staple-macos-artifact.mjs'; @@ -315,6 +316,7 @@ export async function verifyPackagedMacApp( forbidPath = assertMissing, smokeRenderer = smokePackagedRenderer, smokeFilesystemWorker = smokePackagedFilesystemWorker, + assertExecutableUuids = assertMacAppExecutableUuids, workingDirectory = dirname(appPath), environment = process.env, // The architecture the DMG was chosen for, not the one this process happens @@ -354,6 +356,7 @@ export async function verifyPackagedMacApp( const executableArchitectures = await run('lipo', ['-archs', executable]); assertSingleArchitecture(executableArchitectures.stdout, 'Maka executable', expectedArch); + await assertExecutableUuids(appPath, appId); await run('codesign', ['--verify', '--deep', '--strict', '--verbose=2', appPath]); await run('spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath]); await stapleMacosArtifact(appPath, { diff --git a/scripts/verify-packaged-app.test.mjs b/scripts/verify-packaged-app.test.mjs index 7747e89e79..202467ddb9 100644 --- a/scripts/verify-packaged-app.test.mjs +++ b/scripts/verify-packaged-app.test.mjs @@ -529,6 +529,8 @@ test('verifies an Intel macOS package after a transient ticket validation failur const verificationSteps = []; // The archive and update configuration are real; macOS command output and // app launches are the system boundaries this portable test substitutes. + // The executables' LC_UUID stamp has its own suite in macos-executable-uuid.test.mjs. + const stampChecks = []; await verifyPackagedMacApp(app, { expectedArch: 'x64', channel: 'nightly', @@ -538,6 +540,7 @@ test('verifies an Intel macOS package after a transient ticket validation failur smokeRenderer: async () => { verificationSteps.push('renderer'); }, + assertExecutableUuids: async (...args) => stampChecks.push(args), run: async (command, args) => { if (command === 'plutil') { const values = { @@ -567,6 +570,7 @@ test('verifies an Intel macOS package after a transient ticket validation failur }, }); assert.deepEqual(verificationSteps, ['validate', 'validate', 'renderer']); + assert.deepEqual(stampChecks, [[app, 'com.maka.desktop']]); }); describe('assertRenderableAppIcon', () => {