diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index 57f06f765..29854a8c8 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -1,6 +1,6 @@ import "reflect-metadata"; import os from "node:os"; -import { app, BrowserWindow } from "electron"; +import { app, BrowserWindow, dialog } from "electron"; import log from "electron-log/main"; import "./utils/logger"; import "./services/index.js"; @@ -34,6 +34,7 @@ import { getLogFilePath, readChromiumLogTail, } from "./utils/logger"; +import { isMacosPackagedUnsafeBundleLocation } from "./utils/macos-packaged-install-guard"; import { createWindow } from "./window"; // Single instance lock must be acquired FIRST before any other app setup @@ -180,6 +181,31 @@ registerDeepLinkHandlers(); initializePostHog(); app.whenReady().then(async () => { + if ( + process.platform === "darwin" && + app.isPackaged && + isMacosPackagedUnsafeBundleLocation(app.getAppPath(), process.execPath) + ) { + const appPath = app.getAppPath(); + const exePath = process.execPath; + const bundleRoot = exePath.replace(/\/Contents\/MacOS\/[^/]+$/, ""); + log.warn( + "Refusing to start: packaged app is on App Translocation or a read-only non-root volume", + { appPath, exePath }, + ); + dialog.showMessageBoxSync({ + type: "warning", + title: "Move PostHog Code to Applications", + message: `PostHog Code is running from a location with read-only access:\n\n${bundleRoot}`, + detail: + "After quitting, move PostHog Code to your Applications folder, then open it from there.", + buttons: ["Quit"], + defaultId: 0, + }); + app.quit(); + return; + } + const commit = __BUILD_COMMIT__ ?? "dev"; const buildDate = __BUILD_DATE__ ?? "dev"; log.info( diff --git a/apps/code/src/main/utils/macos-packaged-install-guard.test.ts b/apps/code/src/main/utils/macos-packaged-install-guard.test.ts new file mode 100644 index 000000000..49af40d9f --- /dev/null +++ b/apps/code/src/main/utils/macos-packaged-install-guard.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it, vi } from "vitest"; +import { + type DarwinMountEntry, + isMacosAppTranslocationPath, + isMacosPackagedUnsafeBundleLocation, + isMacosPathOnReadOnlyNonRootMountFromTable, + parseDarwinMountTable, + type ReadDarwinMountTable, +} from "./macos-packaged-install-guard"; + +describe("isMacosAppTranslocationPath", () => { + it.each([ + { + case: "appPath is translocated", + appPath: + "/private/var/folders/yf/xx/AppTranslocation/C6283C3C-9D6E-4D81-A7D5-8BA2567ED486/d/PostHog Code.app/Contents/Resources/app.asar", + exePath: "/Applications/PostHog Code.app/Contents/MacOS/PostHog Code", + expected: true, + }, + { + case: "exePath is translocated", + appPath: "/Applications/PostHog Code.app/Contents/Resources/app.asar", + exePath: + "/private/var/folders/yf/xx/AppTranslocation/C6283C3C/d/PostHog Code.app/Contents/MacOS/PostHog Code", + expected: true, + }, + { + case: "neither path is translocated (/Applications)", + appPath: "/Applications/PostHog Code.app/Contents/Resources/app.asar", + exePath: "/Applications/PostHog Code.app/Contents/MacOS/PostHog Code", + expected: false, + }, + { + case: "neither path is translocated (/Users)", + appPath: "/Users/dev/PostHog Code.app/Contents/Resources/app.asar", + exePath: "/Users/dev/PostHog Code.app/Contents/MacOS/PostHog Code", + expected: false, + }, + ])("$case → $expected", ({ appPath, exePath, expected }) => { + expect(isMacosAppTranslocationPath(appPath, exePath)).toBe(expected); + }); +}); + +describe("parseDarwinMountTable", () => { + it.each<{ + case: string; + input: string; + expected: DarwinMountEntry[]; + }>([ + { + case: "standard macOS mount lines", + input: `/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled) +/dev/disk7s1 on /Volumes/My Dmg (apfs, local, read-only, journaled) +/dev/disk5s1 on /Volumes/Writable (apfs, local, journaled) +`, + expected: [ + { + mountPoint: "/", + options: "apfs, sealed, local, read-only, journaled", + }, + { + mountPoint: "/Volumes/My Dmg", + options: "apfs, local, read-only, journaled", + }, + { mountPoint: "/Volumes/Writable", options: "apfs, local, journaled" }, + ], + }, + { + case: "mount point name contains ' (' — anchors to trailing options", + input: + "/dev/disk9s1 on /Volumes/My Backup (2) (apfs, local, read-only, journaled)\n", + expected: [ + { + mountPoint: "/Volumes/My Backup (2)", + options: "apfs, local, read-only, journaled", + }, + ], + }, + ])("parses: $case", ({ input, expected }) => { + expect(parseDarwinMountTable(input)).toEqual(expected); + }); +}); + +describe("isMacosPathOnReadOnlyNonRootMountFromTable", () => { + const baseTable = `/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled) +/dev/disk7s1 on /Volumes/ReadOnlyVol (apfs, local, read-only, journaled) +/dev/disk5s1 on /Volumes/Writable (apfs, local, journaled) +`; + const nestedTable = `/dev/x on / (apfs, read-only) +/dev/y on /Volumes/RW (apfs, local, journaled) +/dev/z on /Volumes/RW/nested (apfs, local, read-only) +`; + + it.each([ + { + case: "path under read-only / is ignored (Users)", + table: baseTable, + path: "/Users/me/app", + expected: false, + }, + { + case: "path under read-only / is ignored (Applications)", + table: baseTable, + path: "/Applications/Foo.app", + expected: false, + }, + { + case: "read-only non-root volume", + table: baseTable, + path: "/Volumes/ReadOnlyVol/PostHog Code.app/Contents/MacOS/PostHog Code", + expected: true, + }, + { + case: "writable non-root volume", + table: baseTable, + path: "/Volumes/Writable/out/PostHog Code.app/Contents/MacOS/PostHog Code", + expected: false, + }, + { + case: "nested read-only mount wins over writable parent", + table: nestedTable, + path: "/Volumes/RW/nested/app", + expected: true, + }, + { + case: "writable parent wins when no deeper match", + table: nestedTable, + path: "/Volumes/RW/other/app", + expected: false, + }, + ])("$case → $expected", ({ table, path, expected }) => { + expect(isMacosPathOnReadOnlyNonRootMountFromTable(path, table)).toBe( + expected, + ); + }); +}); + +describe("isMacosPackagedUnsafeBundleLocation", () => { + const writableMountTable = `/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled) +/dev/disk5s1 on /Volumes/build (apfs, local, journaled) +/dev/disk6s1 on /Applications (apfs, local, journaled) +`; + const readOnlyMountTable = `/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled) +/dev/disk7s1 on /Volumes/ReadOnlyVol (apfs, local, read-only, journaled) +`; + + it.each<{ + case: string; + appPath: string; + exePath: string; + readMountTable: ReadDarwinMountTable; + expected: boolean; + }>([ + { + case: "translocated bundle", + appPath: + "/private/var/.../AppTranslocation/UUID/d/PostHog Code.app/Contents/Resources/app.asar", + exePath: "/Applications/PostHog Code.app/Contents/MacOS/PostHog Code", + readMountTable: () => writableMountTable, + expected: true, + }, + { + case: "ordinary non-translocated path on a writable mount", + appPath: + "/Volumes/build/out/PostHog Code.app/Contents/Resources/app.asar", + exePath: + "/Volumes/build/out/PostHog Code.app/Contents/MacOS/PostHog Code", + readMountTable: () => writableMountTable, + expected: false, + }, + { + case: "bundle on a read-only non-root volume", + appPath: + "/Volumes/ReadOnlyVol/PostHog Code.app/Contents/Resources/app.asar", + exePath: + "/Volumes/ReadOnlyVol/PostHog Code.app/Contents/MacOS/PostHog Code", + readMountTable: () => readOnlyMountTable, + expected: true, + }, + { + case: "mount table cannot be read (degrade to non-blocking)", + appPath: "/Applications/PostHog Code.app/Contents/Resources/app.asar", + exePath: "/Applications/PostHog Code.app/Contents/MacOS/PostHog Code", + readMountTable: () => null, + expected: false, + }, + ])("$case → $expected", ({ appPath, exePath, readMountTable, expected }) => { + expect( + isMacosPackagedUnsafeBundleLocation(appPath, exePath, readMountTable), + ).toBe(expected); + }); + + it("short-circuits on translocation without reading the mount table", () => { + const readMountTable = vi.fn(() => writableMountTable); + isMacosPackagedUnsafeBundleLocation( + "/private/var/.../AppTranslocation/UUID/d/PostHog Code.app/Contents/Resources/app.asar", + "/Applications/PostHog Code.app/Contents/MacOS/PostHog Code", + readMountTable, + ); + expect(readMountTable).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/code/src/main/utils/macos-packaged-install-guard.ts b/apps/code/src/main/utils/macos-packaged-install-guard.ts new file mode 100644 index 000000000..a0a11578c --- /dev/null +++ b/apps/code/src/main/utils/macos-packaged-install-guard.ts @@ -0,0 +1,138 @@ +import { execFileSync } from "node:child_process"; +import path from "node:path"; + +const APP_TRANSLOCATION_SEGMENT = "AppTranslocation"; +const MOUNT_READ_TIMEOUT_MS = 3000; + +export type DarwinMountEntry = { + mountPoint: string; + options: string; +}; + +/** + * Reads the Darwin mount table. Returns `null` when the table cannot be + * obtained (e.g. `/sbin/mount` is missing, times out, or exits non-zero). + */ +export type ReadDarwinMountTable = () => string | null; + +/** Parse `/sbin/mount` lines: ` on ()` */ +export function parseDarwinMountTable(output: string): DarwinMountEntry[] { + const entries: DarwinMountEntry[] = []; + for (const line of output.split("\n")) { + const onMarker = line.indexOf(" on "); + if (onMarker === -1) continue; + const afterOn = line.slice(onMarker + 4); + // `lastIndexOf` anchors to the trailing options block, so mount points + // whose display names contain " (" (e.g. "/Volumes/My Backup (2)") still + // parse correctly. The `line.endsWith(")")` check guarantees those parens + // really are the options. + const openParen = afterOn.lastIndexOf(" ("); + if (openParen === -1 || !line.endsWith(")")) continue; + const mountPoint = afterOn.slice(0, openParen); + const options = afterOn.slice(openParen + 2, -1); + entries.push({ mountPoint, options }); + } + return entries; +} + +function mountOptionsImplyReadOnly(options: string): boolean { + return options.toLowerCase().includes("read-only"); +} + +function longestMatchingMount( + resolvedPath: string, + entries: DarwinMountEntry[], +): DarwinMountEntry | null { + let best: DarwinMountEntry | null = null; + for (const e of entries) { + const mp = e.mountPoint; + // For `/` we'd otherwise build `//` which no real path starts with, so the + // root mount would silently drop out of the comparison and the + // `best.mountPoint === "/"` guard below would be unreachable. + const under = + resolvedPath === mp || + resolvedPath.startsWith(mp === "/" ? "/" : `${mp}/`); + if (!under) continue; + if (!best || mp.length > best.mountPoint.length) { + best = e; + } + } + return best; +} + +/** + * True when `resolvedAbsolutePath` sits on a **non-root** mount that `mount(8)` + * reports as read-only (e.g. many DMGs, some external volumes). + * + * Ignores read-only `/` — on sealed macOS the system volume is read-only while + * normal apps under /Applications or /Users still work. + */ +export function isMacosPathOnReadOnlyNonRootMountFromTable( + resolvedAbsolutePath: string, + mountTable: string, +): boolean { + const normalized = path.resolve(resolvedAbsolutePath); + const entries = parseDarwinMountTable(mountTable); + const best = longestMatchingMount(normalized, entries); + if (!best || best.mountPoint === "/") { + return false; + } + return mountOptionsImplyReadOnly(best.options); +} + +/** + * Reads `/sbin/mount` synchronously. A short timeout keeps a hung NFS/SMB + * share from freezing app startup — the exact failure mode this guard exists + * to prevent. Returns `null` on any failure so callers can degrade to "don't + * block". + */ +function readDarwinMountTableSync(): string | null { + try { + return execFileSync("/sbin/mount", { + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + timeout: MOUNT_READ_TIMEOUT_MS, + }); + } catch { + return null; + } +} + +/** + * True when either path is under macOS App Translocation (read-only runtime). + * Caller should gate on packaged darwin before using this to block startup. + */ +export function isMacosAppTranslocationPath( + appPath: string, + exePath: string, +): boolean { + return ( + appPath.includes(APP_TRANSLOCATION_SEGMENT) || + exePath.includes(APP_TRANSLOCATION_SEGMENT) + ); +} + +/** + * Packaged macOS: translocated bundle path, or binary on a non-root read-only + * mount (see mount(8)). + * + * `readMountTable` is injectable so tests can drive the mount-table branch + * deterministically instead of relying on the host's real `/sbin/mount`. + */ +export function isMacosPackagedUnsafeBundleLocation( + appPath: string, + exePath: string, + readMountTable: ReadDarwinMountTable = readDarwinMountTableSync, +): boolean { + if (isMacosAppTranslocationPath(appPath, exePath)) { + return true; + } + const table = readMountTable(); + if (table === null) { + return false; + } + return isMacosPathOnReadOnlyNonRootMountFromTable( + path.resolve(exePath), + table, + ); +}