diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32093062..a6da7661 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -151,8 +151,10 @@ jobs: - name: Build unsigned macOS packages run: pnpm package:mac:unsigned - - name: Inspect macOS zip - run: pnpm inspect:package -- release/*.zip + - name: Inspect macOS packages + run: | + pnpm inspect:package -- release/*.zip + pnpm inspect:dmg -- release/*.dmg - name: Stage macOS artifacts shell: bash diff --git a/package.json b/package.json index 76649a56..58bf2d48 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "package:mac:unsigned": "node scripts/package-mac-unsigned.mjs", "package:dir": "pnpm prepackage && node scripts/run-electron-builder.mjs --linux --x64 --dir", "inspect:package": "node scripts/inspect-package.mjs", - "test:packaging": "node --test scripts/packaging.test.mjs", + "inspect:dmg": "node scripts/inspect-macos-dmg.mjs", + "test:packaging": "node --test scripts/packaging.test.mjs scripts/inspect-macos-dmg.test.mjs", "test:tooling": "node --test scripts/check-structure.test.mjs scripts/check-provenance.test.mjs scripts/deploy-site.test.mjs scripts/tailnet-gateway.test.mjs scripts/tailnet-service.test.mjs", "check:structure": "node scripts/check-structure.mjs", "check:provenance": "node scripts/check-provenance.mjs", diff --git a/scripts/inspect-macos-dmg.mjs b/scripts/inspect-macos-dmg.mjs new file mode 100644 index 00000000..c2f6b820 --- /dev/null +++ b/scripts/inspect-macos-dmg.mjs @@ -0,0 +1,143 @@ +import { mkdtempSync, readdirSync, rmdirSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { extname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +import { inspectPackage, locateAppRoot } from "./inspect-package.mjs"; + +function asError(error) { + return error instanceof Error ? error : new Error(String(error)); +} + +function appendCleanupError(primary, cleanup, message) { + const cleanupError = asError(cleanup); + if (primary === null) return cleanupError; + return new AggregateError([primary, cleanupError], message); +} + +function defaultRunCommand(command, args) { + const result = spawnSync(command, args, { stdio: "inherit" }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${command} exited with status ${result.status ?? "unknown"}`); + } +} + +function defaultIsFile(path) { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +function defaultReadMountedEntries(path) { + return readdirSync(path, { withFileTypes: true }); +} + +export function findMountedApp(mountPoint, readMountedEntries = defaultReadMountedEntries) { + const apps = readMountedEntries(mountPoint) + .filter((entry) => entry.isDirectory() && extname(entry.name).toLowerCase() === ".app") + .map((entry) => join(mountPoint, entry.name)); + if (apps.length !== 1) { + throw new Error(`expected exactly one macOS app in mounted DMG; found ${apps.length}`); + } + return apps[0]; +} + +export function inspectMacosDmg(dmgPath, dependencies = {}) { + const platform = dependencies.platform ?? process.platform; + if (platform !== "darwin") { + throw new Error(`DMG inspection requires macOS (darwin); current platform is ${platform}`); + } + + const absoluteDmg = resolve(dmgPath); + const isFile = dependencies.isFile ?? defaultIsFile; + if (extname(absoluteDmg).toLowerCase() !== ".dmg" || !isFile(absoluteDmg)) { + throw new Error(`DMG does not exist or is not a file: ${absoluteDmg}`); + } + + const createMountPoint = + dependencies.createMountPoint ?? (() => mkdtempSync(join(tmpdir(), "t4-code-dmg-"))); + const removeMountPoint = dependencies.removeMountPoint ?? rmdirSync; + const readMountedEntries = dependencies.readMountedEntries ?? defaultReadMountedEntries; + const inspectArtifact = dependencies.inspectArtifact ?? inspectPackage; + const resolveAppRoot = dependencies.resolveAppRoot ?? locateAppRoot; + const runCommand = dependencies.runCommand ?? defaultRunCommand; + const mountPoint = createMountPoint(); + + let failure = null; + let mounted = false; + let detached = false; + let inspection; + try { + runCommand("hdiutil", [ + "attach", + "-readonly", + "-nobrowse", + "-noautoopen", + "-mountpoint", + mountPoint, + absoluteDmg, + ]); + mounted = true; + const appPath = findMountedApp(mountPoint, readMountedEntries); + inspection = inspectArtifact(resolveAppRoot(appPath)); + } catch (error) { + failure = asError(error); + } finally { + if (mounted) { + try { + runCommand("hdiutil", ["detach", mountPoint]); + detached = true; + } catch (normalDetachError) { + try { + runCommand("hdiutil", ["detach", "-force", mountPoint]); + detached = true; + } catch (forceDetachError) { + failure = appendCleanupError( + failure, + new AggregateError( + [asError(normalDetachError), asError(forceDetachError)], + `could not detach mounted DMG at ${mountPoint}`, + ), + "DMG inspection failed and cleanup could not detach the image", + ); + } + } + } + + if (!mounted || detached) { + try { + removeMountPoint(mountPoint); + } catch (error) { + failure = appendCleanupError( + failure, + error, + "DMG inspection failed and cleanup could not remove its mount point", + ); + } + } + } + + if (failure !== null) throw failure; + return inspection; +} + +const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (isMain) { + const paths = process.argv.slice(2).filter((argument) => argument !== "--"); + if (paths.length !== 1) { + console.error("usage: pnpm inspect:dmg -- "); + process.exitCode = 1; + } else { + try { + const result = inspectMacosDmg(paths[0]); + console.log(`${paths[0]}: mounted read-only and inspected ${result.asarEntries} ASAR entries`); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } + } +} diff --git a/scripts/inspect-macos-dmg.test.mjs b/scripts/inspect-macos-dmg.test.mjs new file mode 100644 index 00000000..9fd08bb1 --- /dev/null +++ b/scripts/inspect-macos-dmg.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { resolve } from "node:path"; + +import { findMountedApp, inspectMacosDmg } from "./inspect-macos-dmg.mjs"; + +const dmgPath = "/tmp/T4-Code-0.1.3-mac-arm64.dmg"; +const mountPoint = "/tmp/t4-code-dmg-test"; + +function directory(name) { + return { name, isDirectory: () => true }; +} + +function fixture(overrides = {}) { + const calls = []; + const dependencies = { + platform: "darwin", + isFile: () => true, + createMountPoint: () => mountPoint, + readMountedEntries: () => [directory("T4 Code.app")], + resolveAppRoot: (path) => `${path}/Contents`, + inspectArtifact: (path) => { + calls.push(["inspect", path]); + return { asarEntries: 42 }; + }, + runCommand: (command, args) => calls.push([command, ...args]), + removeMountPoint: (path) => calls.push(["remove", path]), + ...overrides, + }; + return { calls, dependencies }; +} + +test("DMG inspection mounts read-only, inspects the app, detaches, then removes the mount point", () => { + const { calls, dependencies } = fixture(); + assert.deepEqual(inspectMacosDmg(dmgPath, dependencies), { asarEntries: 42 }); + assert.deepEqual(calls, [ + [ + "hdiutil", + "attach", + "-readonly", + "-nobrowse", + "-noautoopen", + "-mountpoint", + mountPoint, + dmgPath, + ], + ["inspect", `${mountPoint}/T4 Code.app/Contents`], + ["hdiutil", "detach", mountPoint], + ["remove", mountPoint], + ]); +}); + +test("DMG inspection detaches even when package inspection fails", () => { + const { calls, dependencies } = fixture({ + inspectArtifact: (path) => { + calls.push(["inspect", path]); + throw new Error("bad package"); + }, + }); + assert.throws(() => inspectMacosDmg(dmgPath, dependencies), /bad package/); + assert.deepEqual(calls.slice(-2), [ + ["hdiutil", "detach", mountPoint], + ["remove", mountPoint], + ]); +}); + +test("DMG inspection force-detaches when the normal detach is busy", () => { + const { calls, dependencies } = fixture({ + runCommand: (command, args) => { + calls.push([command, ...args]); + if (args[0] === "detach" && args[1] !== "-force") throw new Error("busy"); + }, + }); + assert.deepEqual(inspectMacosDmg(dmgPath, dependencies), { asarEntries: 42 }); + assert.deepEqual(calls.slice(-3), [ + ["hdiutil", "detach", mountPoint], + ["hdiutil", "detach", "-force", mountPoint], + ["remove", mountPoint], + ]); +}); + +test("DMG inspection fails closed and preserves the mount point when detach cannot complete", () => { + const { calls, dependencies } = fixture({ + runCommand: (command, args) => { + calls.push([command, ...args]); + if (args[0] === "detach") throw new Error("still busy"); + }, + }); + assert.throws(() => inspectMacosDmg(dmgPath, dependencies), /could not detach mounted DMG/); + assert.equal(calls.some(([command]) => command === "remove"), false); +}); + +test("mounted DMGs must contain exactly one application bundle", () => { + assert.equal( + findMountedApp(mountPoint, () => [directory("T4 Code.app")]), + `${mountPoint}/T4 Code.app`, + ); + assert.throws(() => findMountedApp(mountPoint, () => []), /found 0/); + assert.throws( + () => findMountedApp(mountPoint, () => [directory("One.app"), directory("Two.app")]), + /found 2/, + ); +}); + +test("release workflow inspects the DMG before staging it for publication", () => { + const workflow = readFileSync(resolve(import.meta.dirname, "../.github/workflows/release.yml"), "utf8"); + const inspect = workflow.indexOf("pnpm inspect:dmg -- release/*.dmg"); + const stage = workflow.indexOf("cp release/T4-Code-*.dmg"); + assert.ok(inspect >= 0, "release workflow must invoke the DMG inspector"); + assert.ok(stage > inspect, "DMG inspection must run before staging the artifact"); +});