-
Notifications
You must be signed in to change notification settings - Fork 18
fix/macos-packaged-readonly-startup #2122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,171 @@ | ||
| import { describe, expect, it, vi } from "vitest"; | ||
| import { | ||
| isMacosAppTranslocationPath, | ||
| isMacosPackagedUnsafeBundleLocation, | ||
| isMacosPathOnReadOnlyNonRootMountFromTable, | ||
| parseDarwinMountTable, | ||
| } from "./macos-packaged-install-guard"; | ||
|
|
||
| describe("isMacosAppTranslocationPath", () => { | ||
| it("returns true when appPath contains AppTranslocation", () => { | ||
| expect( | ||
| isMacosAppTranslocationPath( | ||
| "/private/var/folders/yf/xx/AppTranslocation/C6283C3C-9D6E-4D81-A7D5-8BA2567ED486/d/PostHog Code.app/Contents/Resources/app.asar", | ||
| "/Applications/PostHog Code.app/Contents/MacOS/PostHog Code", | ||
| ), | ||
| ).toBe(true); | ||
| }); | ||
|
|
||
| it("returns true when exePath contains AppTranslocation", () => { | ||
| expect( | ||
| isMacosAppTranslocationPath( | ||
| "/Applications/PostHog Code.app/Contents/Resources/app.asar", | ||
| "/private/var/folders/yf/xx/AppTranslocation/C6283C3C/d/PostHog Code.app/Contents/MacOS/PostHog Code", | ||
| ), | ||
| ).toBe(true); | ||
| }); | ||
|
|
||
| it("returns false for normal /Applications paths", () => { | ||
| expect( | ||
| isMacosAppTranslocationPath( | ||
| "/Applications/PostHog Code.app/Contents/Resources/app.asar", | ||
| "/Applications/PostHog Code.app/Contents/MacOS/PostHog Code", | ||
| ), | ||
| ).toBe(false); | ||
| }); | ||
|
|
||
| it("returns false when neither path is translocated", () => { | ||
| expect( | ||
| isMacosAppTranslocationPath( | ||
| "/Users/dev/PostHog Code.app/Contents/Resources/app.asar", | ||
| "/Users/dev/PostHog Code.app/Contents/MacOS/PostHog Code", | ||
| ), | ||
| ).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe("parseDarwinMountTable", () => { | ||
| it("parses standard macOS mount lines", () => { | ||
| const sample = `/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) | ||
| `; | ||
| const entries = parseDarwinMountTable(sample); | ||
| expect(entries).toEqual([ | ||
| { 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" }, | ||
| ]); | ||
| }); | ||
| }); | ||
|
|
||
| describe("isMacosPathOnReadOnlyNonRootMountFromTable", () => { | ||
| const table = `/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) | ||
| `; | ||
|
|
||
| it("returns false for paths only under read-only / (root is ignored)", () => { | ||
| expect( | ||
| isMacosPathOnReadOnlyNonRootMountFromTable("/Users/me/app", table), | ||
| ).toBe(false); | ||
| expect( | ||
| isMacosPathOnReadOnlyNonRootMountFromTable( | ||
| "/Applications/Foo.app", | ||
| table, | ||
| ), | ||
| ).toBe(false); | ||
| }); | ||
|
|
||
| it("returns true for paths on a read-only non-root volume", () => { | ||
| expect( | ||
| isMacosPathOnReadOnlyNonRootMountFromTable( | ||
| "/Volumes/ReadOnlyVol/PostHog Code.app/Contents/MacOS/PostHog Code", | ||
| table, | ||
| ), | ||
| ).toBe(true); | ||
| }); | ||
|
|
||
| it("returns false for paths on a writable volume", () => { | ||
| expect( | ||
| isMacosPathOnReadOnlyNonRootMountFromTable( | ||
| "/Volumes/Writable/out/PostHog Code.app/Contents/MacOS/PostHog Code", | ||
| table, | ||
| ), | ||
| ).toBe(false); | ||
| }); | ||
|
|
||
| it("picks the longest matching mount prefix", () => { | ||
| const nested = `/dev/x on / (apfs, read-only) | ||
| /dev/y on /Volumes/RW (apfs, local, journaled) | ||
| /dev/z on /Volumes/RW/nested (apfs, local, read-only) | ||
| `; | ||
| expect( | ||
| isMacosPathOnReadOnlyNonRootMountFromTable( | ||
| "/Volumes/RW/nested/app", | ||
| nested, | ||
| ), | ||
| ).toBe(true); | ||
| expect( | ||
| isMacosPathOnReadOnlyNonRootMountFromTable( | ||
| "/Volumes/RW/other/app", | ||
| nested, | ||
| ), | ||
| ).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| 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("is true when translocated (mount table never consulted)", () => { | ||
| const readMountTable = vi.fn(() => writableMountTable); | ||
| expect( | ||
| isMacosPackagedUnsafeBundleLocation( | ||
| "/private/var/.../AppTranslocation/UUID/d/PostHog Code.app/Contents/Resources/app.asar", | ||
| "/Applications/PostHog Code.app/Contents/MacOS/PostHog Code", | ||
| readMountTable, | ||
| ), | ||
| ).toBe(true); | ||
| expect(readMountTable).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("is false for ordinary non-translocated paths on writable mounts", () => { | ||
| expect( | ||
| isMacosPackagedUnsafeBundleLocation( | ||
| "/Volumes/build/out/PostHog Code.app/Contents/Resources/app.asar", | ||
| "/Volumes/build/out/PostHog Code.app/Contents/MacOS/PostHog Code", | ||
| () => writableMountTable, | ||
| ), | ||
| ).toBe(false); | ||
| }); | ||
|
|
||
| it("is true when the bundle lives on a read-only non-root volume", () => { | ||
| expect( | ||
| isMacosPackagedUnsafeBundleLocation( | ||
| "/Volumes/ReadOnlyVol/PostHog Code.app/Contents/Resources/app.asar", | ||
| "/Volumes/ReadOnlyVol/PostHog Code.app/Contents/MacOS/PostHog Code", | ||
| () => readOnlyMountTable, | ||
| ), | ||
| ).toBe(true); | ||
| }); | ||
|
|
||
| it("is false when the mount table cannot be read (degrade to non-blocking)", () => { | ||
| expect( | ||
| isMacosPackagedUnsafeBundleLocation( | ||
| "/Applications/PostHog Code.app/Contents/Resources/app.asar", | ||
| "/Applications/PostHog Code.app/Contents/MacOS/PostHog Code", | ||
| () => null, | ||
| ), | ||
| ).toBe(false); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,134 @@ | ||||||
| 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: `<device> on <mountPoint> (<opts>)` */ | ||||||
| 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); | ||||||
| const openParen = afterOn.indexOf(" ("); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Prompt To Fix With AIThis is a comment left during a code review.
Path: apps/code/src/main/utils/macos-packaged-install-guard.ts
Line: 25
Comment:
Mount points whose display names contain ` (` — e.g. `/Volumes/My Backup (2)` — are parsed incorrectly. `indexOf(" (")` stops at the first occurrence inside the name, so `mountPoint` is truncated and the options field picks up the tail of the display name. Using `lastIndexOf` anchors to the options block that macOS always places at the end of the line, matching the `line.endsWith(")")` invariant already enforced above.
```suggestion
const openParen = afterOn.lastIndexOf(" (");
```
How can I resolve this? If you propose a fix, please make it concise. |
||||||
| 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, | ||||||
| ); | ||||||
| } | ||||||
Uh oh!
There was an error while loading. Please reload this page.