diff --git a/src/main/persistence/profile-state/profile-state-access-identity.test.ts b/src/main/persistence/profile-state/profile-state-access-identity.test.ts index a55e8610444..bd7f330ea19 100644 --- a/src/main/persistence/profile-state/profile-state-access-identity.test.ts +++ b/src/main/persistence/profile-state/profile-state-access-identity.test.ts @@ -3,8 +3,10 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' const native = vi.hoisted(() => ({ registry: vi.fn(), - creationTime: vi.fn() + creationTime: vi.fn(), + runProcessSync: vi.fn() })) +vi.mock('@orca/process-host', () => ({ runProcessSync: native.runProcessSync })) vi.mock('../../windows-native-registry', () => ({ WINDOWS_REG_SZ: 1, loadWindowsNativeRegistry: () => ({ HK: { LM: 123 }, getRegistryKey: native.registry }) @@ -21,6 +23,7 @@ beforeEach(() => { vi.resetModules() native.registry.mockReset() native.creationTime.mockReset().mockReturnValue(null) + native.runProcessSync.mockReset() }) afterEach(() => { @@ -99,3 +102,36 @@ it.each([ const { profileStateAccessMachineIdentity } = await import('./profile-state-access-identity') expect(profileStateAccessMachineIdentity()).toBe(expected) }) + +const IOREG = `+-o J316sAP + { + "IOPlatformSerialNumber" = "C02XXXXXXXXX" + "IOPlatformUUID" = "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE" + } +` + +it('reads the macOS hardware UUID from IOPlatformUUID, not a sysctl macOS lacks', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + native.runProcessSync.mockReturnValue({ code: 0, stdout: IOREG, stderr: '' }) + const { profileStateAccessMachineIdentity } = await import('./profile-state-access-identity') + expect(profileStateAccessMachineIdentity()).toBe('aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee') + expect(profileStateAccessMachineIdentity()).toBe('aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee') + expect(native.runProcessSync).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + program: '/usr/sbin/ioreg', + args: ['-rd1', '-c', 'IOPlatformExpertDevice'] + }) + ) +}) + +it.each([ + [{ code: 1, stdout: '', stderr: 'ioreg: failed' }], + [{ code: 0, stdout: '"IOPlatformUUID" = "00000000-0000-0000-0000-000000000000"', stderr: '' }], + [{ code: 0, stdout: '"IOPlatformUUID" = "not-a-uuid"', stderr: '' }], + [{ code: 0, stdout: '', stderr: '' }] +])('keeps an unavailable macOS hardware UUID unverifiable', async (result) => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + native.runProcessSync.mockReturnValue(result) + const { profileStateAccessMachineIdentity } = await import('./profile-state-access-identity') + expect(profileStateAccessMachineIdentity()).toBeNull() +}) diff --git a/src/main/persistence/profile-state/profile-state-access-identity.ts b/src/main/persistence/profile-state/profile-state-access-identity.ts index 427c4f291ed..50302692f6d 100644 --- a/src/main/persistence/profile-state/profile-state-access-identity.ts +++ b/src/main/persistence/profile-state/profile-state-access-identity.ts @@ -35,13 +35,14 @@ export function profileStateAccessMachineIdentity(): string | null { ? `win32-machine-guid:${value}` : null } else if (process.platform === 'darwin') { + // Why not `sysctl kern.hostuuid`: that OID does not exist on macOS (#29259). const result = runProcessSync({ - program: '/usr/sbin/sysctl', - args: ['-n', 'kern.hostuuid'], + program: '/usr/sbin/ioreg', + args: ['-rd1', '-c', 'IOPlatformExpertDevice'], timeoutMs: 1_000, - maxOutputBytes: 1024 + maxOutputBytes: 64 * 1024 }) - machineIdentity = result.code === 0 ? result.stdout.trim() || null : null + machineIdentity = result.code === 0 ? parseDarwinPlatformUuid(result.stdout) : null } } catch { // Missing machine identity cannot establish ownership across a reboot. @@ -49,6 +50,19 @@ export function profileStateAccessMachineIdentity(): string | null { return machineIdentity } +/** + * Lowercased so this matches what the SSH machine-home probe reads from the same source + * (`orcad-machine-home.ts`); the two compare owner records against each other. + */ +export function parseDarwinPlatformUuid(ioregOutput: string): string | null { + const value = /"IOPlatformUUID"\s*=\s*"([^"]*)"/u.exec(ioregOutput)?.[1]?.toLowerCase() + return value && + /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/u.test(value) && + !/^[0-]+$/u.test(value) + ? value + : null +} + /** A kernel boot UUID survives hostname changes without conflating machines sharing a profile. */ export function profileStateAccessBootIdentity(): string | null { if (bootIdentity !== undefined) { diff --git a/src/main/ssh/orcad-machine-home.test.ts b/src/main/ssh/orcad-machine-home.test.ts index 183e8258e50..6c5ba100a5e 100644 --- a/src/main/ssh/orcad-machine-home.test.ts +++ b/src/main/ssh/orcad-machine-home.test.ts @@ -44,6 +44,7 @@ const { resolveOrcadMachineHome } = await import('./orcad-remote-context') const { decideOrcadMachineHome, orcadMachineIdentity, + ORCAD_MACHINE_CLAIM_ARBITER_DIRNAME, ORCAD_MACHINE_CLAIM_FILENAME, ORCAD_MACHINE_HOMES_DIRNAME } = await import('./orcad-machine-home') @@ -72,20 +73,54 @@ afterEach(() => { rmSync(scratch, { recursive: true, force: true }) }) -/** The fake machine's hardware UUID, as `sysctl -n kern.hostuuid` answers it. */ +/** The fake machine's hardware UUID, as `ioreg` answers its `IOPlatformUUID`. */ function uuidOf(name: string): string { const hex = Buffer.from(name.padEnd(16, '-')).toString('hex').slice(0, 32) return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}` } +function ioregOutput(name: string): string { + return `+-o J316sAP \n "IOPlatformUUID" = "${uuidOf(name).toUpperCase()}"\n` +} + +type MachineOptions = { + /** A filesystem without hard links: every `ln` fails. */ + noHardLinks?: boolean + /** Holds each machine's write of the claim until both arrive, then delays this one's. */ + claimWriteDelay?: number +} + /** A (macOS-flavored) machine sharing `home`, as on an NFS-mounted cluster home. */ -function machine(name: string, hostname = name) { +function machine(name: string, hostname = name, options: MachineOptions = {}) { const bin = join(scratch, `bin-${name}-${hostname}`) mkdirSync(bin, { recursive: true }) - writeFileSync(join(bin, 'uname'), `#!/bin/sh\necho ${hostname}\n`) - writeFileSync(join(bin, 'sysctl'), `#!/bin/sh\necho ${uuidOf(name)}\n`) - chmodSync(join(bin, 'uname'), 0o755) - chmodSync(join(bin, 'sysctl'), 0o755) + const tool = (tool: string, script: string): void => { + writeFileSync(join(bin, tool), `#!/bin/sh\n${script}\n`) + chmodSync(join(bin, tool), 0o755) + } + tool('uname', `echo ${hostname}`) + tool('ioreg', `printf '%s' '${ioregOutput(name)}'`) + // As on macOS, which has no such OID. + tool('sysctl', `echo "sysctl: unknown oid '$2'" >&2; exit 1`) + if (options.noHardLinks) { + tool('ln', 'echo "ln: Operation not supported" >&2; exit 1') + } + if (options.claimWriteDelay !== undefined) { + const barrier = join(scratch, 'claim-write-barrier') + mkdirSync(barrier, { recursive: true }) + tool( + 'mv', + [ + 'for last; do :; done', + `case "$last" in */${ORCAD_MACHINE_CLAIM_FILENAME})`, + ` : > '${barrier}/'$$; i=0`, + ` while [ "$(ls '${barrier}' | wc -l)" -lt 2 ] && [ $i -lt 40 ]; do sleep 0.05; i=$((i + 1)); done`, + ` sleep ${options.claimWriteDelay};;`, + 'esac', + 'exec /bin/mv "$@"' + ].join('\n') + ) + } const env = { ...process.env, HOME: home, @@ -106,7 +141,7 @@ function claimPath(): string { return join(home, '.orca-remote', ORCAD_MACHINE_CLAIM_FILENAME) } -function legacyOwner(name: string): void { +function legacyOwner(name: string, machineIdentity: string | null = uuidOf(name)): void { const dir = join(home, '.orca', '.profile-state-access', 'participants', 'token') mkdirSync(dir, { recursive: true }) writeFileSync( @@ -116,7 +151,7 @@ function legacyOwner(name: string): void { pid: 4242, host: name, platform: 'darwin', - machineIdentity: uuidOf(name) + ...(machineIdentity === null ? {} : { machineIdentity }) }) ) } @@ -209,6 +244,58 @@ describe.runIf(POSIX)('resolveOrcadMachineHome on a home several machines share' ) }) + it('keeps a Mac whose orcad predates macOS machine identity on its running install', async () => { + const a = machine('node-a') + mkdirSync(join(home, '.orca-remote', 'orcad-0.1.0+abc'), { recursive: true }) + // Older orcads on macOS recorded no machine identity at all. + legacyOwner('node-a', null) + expect(await resolveOrcadMachineHome(a, true)).toEqual(loginHomeResult()) + expect(await resolveOrcadMachineHome(a, true)).toEqual(loginHomeResult()) + }) + + it('lets exactly one machine claim a home on a filesystem without hard links', async () => { + const a = machine('node-a', 'node-a', { noHardLinks: true, claimWriteDelay: 0 }) + const b = machine('node-b', 'node-b', { noHardLinks: true, claimWriteDelay: 0.5 }) + const homes = await Promise.all([ + resolveOrcadMachineHome(a, true), + resolveOrcadMachineHome(b, true) + ]) + expect(homes.filter((resolved) => resolved.orcadHome === home)).toHaveLength(1) + expect(readdirNames(join(home, '.orca-remote'))).toEqual( + [ + ORCAD_MACHINE_CLAIM_ARBITER_DIRNAME, + ORCAD_MACHINE_CLAIM_FILENAME, + ORCAD_MACHINE_HOMES_DIRNAME + ].sort() + ) + expect(readdirNames(join(home, '.orca-remote', ORCAD_MACHINE_CLAIM_ARBITER_DIRNAME))).toEqual([ + ORCAD_MACHINE_CLAIM_FILENAME + ]) + // Stable on later connects. + expect( + await Promise.all([resolveOrcadMachineHome(a, true), resolveOrcadMachineHome(b, true)]) + ).toEqual(homes) + }) + + it('lets exactly one of two machines replace an empty claim', async () => { + mkdirSync(join(home, '.orca-remote')) + writeFileSync(claimPath(), '') + const homes = await Promise.all([ + resolveOrcadMachineHome(machine('node-a', 'node-a', { claimWriteDelay: 0 }), true), + resolveOrcadMachineHome(machine('node-b', 'node-b', { claimWriteDelay: 0.5 }), true) + ]) + expect(homes.filter((resolved) => resolved.orcadHome === home)).toHaveLength(1) + }) + + it('restores an emptied claim to the machine the arbiter already chose', async () => { + const a = machine('node-a', 'node-a', { noHardLinks: true }) + const b = machine('node-b', 'node-b', { noHardLinks: true }) + expect(await resolveOrcadMachineHome(a, true)).toEqual(loginHomeResult()) + writeFileSync(claimPath(), '') + expect((await resolveOrcadMachineHome(b, true)).orcadHome).not.toBe(home) + expect(await resolveOrcadMachineHome(a, true)).toEqual(loginHomeResult()) + }) + it('keeps a renamed machine on its own install', async () => { expect(await resolveOrcadMachineHome(machine('node-a'), true)).toEqual(loginHomeResult()) expect(await resolveOrcadMachineHome(machine('node-a', 'renamed'), true)).toEqual( @@ -265,12 +352,18 @@ describe('decideOrcadMachineHome', () => { it('keys macOS by hardware UUID alone, since its hostname follows the network', () => { const uuid = '12345678-1234-1234-1234-123456789ABC' - expect(orcadMachineIdentity(darwin, uuid, 'cafe-wifi')?.key).toBe( - orcadMachineIdentity(darwin, uuid, 'home-wifi')?.key + const ioreg = ` "IOPlatformUUID" = "${uuid}"` + expect(orcadMachineIdentity(darwin, ioreg, 'cafe-wifi')?.key).toBe( + orcadMachineIdentity(darwin, ioreg, 'home-wifi')?.key ) + expect(orcadMachineIdentity(darwin, ioreg, 'cafe-wifi')?.machineIdentity).toBe( + uuid.toLowerCase() + ) + // What `sysctl -n kern.hostuuid` printed on macOS: nothing. + expect(orcadMachineIdentity(darwin, '', 'cafe-wifi')).toBeNull() const renamed = probe(darwin, { - identity: orcadMachineIdentity(darwin, uuid, 'cafe-wifi'), - dataRootOwners: [{ host: 'home-wifi', machineIdentity: uuid }] + identity: orcadMachineIdentity(darwin, ioreg, 'cafe-wifi'), + dataRootOwners: [{ host: 'home-wifi', machineIdentity: uuid.toLowerCase() }] }) expect(decideOrcadMachineHome(renamed, darwin).kind).toBe('claim') }) diff --git a/src/main/ssh/orcad-machine-home.ts b/src/main/ssh/orcad-machine-home.ts index ee3e64c9ce7..da4d758dcc8 100644 --- a/src/main/ssh/orcad-machine-home.ts +++ b/src/main/ssh/orcad-machine-home.ts @@ -13,6 +13,7 @@ import { createHash, randomUUID } from 'node:crypto' import { shellEscape } from './ssh-connection-utils' import { RELAY_REMOTE_DIR } from './relay-protocol' +import { parseDarwinPlatformUuid } from '../persistence/profile-state/profile-state-access-identity' import { joinRemotePath, normalizeRemoteHome, @@ -25,6 +26,8 @@ export const ORCAD_MACHINE_CLAIM_FILENAME = 'orcad-machine.json' /** Under `~/.orca-remote`: one orcad home per other machine sharing this `$HOME`. */ export const ORCAD_MACHINE_HOMES_DIRNAME = 'hosts' export const ORCAD_MACHINE_CLAIM_SCHEMA_VERSION = 1 +/** Under `~/.orca-remote`: decides the claim when hard links cannot (see the claim command). */ +export const ORCAD_MACHINE_CLAIM_ARBITER_DIRNAME = 'orcad-machine.arbiter' const SECTION = '__ORCA_MACHINE_HOME_SECTION__' const CLAIM_ABSENT = 'ABSENT' @@ -74,7 +77,9 @@ export function orcadMachineHomeProbeCommand(host: RemoteHostPlatform): string { return [ `printf '%s\\n' "$HOME"; echo ${SECTION};`, host.os === 'darwin' - ? 'sysctl -n kern.hostuuid 2>/dev/null;' + ? // Bare first so a test (or a host that moved it) can supply its own ioreg. + '{ ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null ||' + + ' /usr/sbin/ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null; } | grep IOPlatformUUID;' : 'cat /etc/machine-id 2>/dev/null;', `echo; echo ${SECTION}; uname -n 2>/dev/null; echo; echo ${SECTION};`, // An empty claim is one a full disk cut short; the claim command replaces it. @@ -128,9 +133,7 @@ export function orcadMachineIdentity( ): OrcadMachineIdentity | null { const machineIdentity = host.os === 'darwin' - ? /^[0-9A-Fa-f-]{36}$/u.test(machineRaw) - ? machineRaw.toLowerCase() - : null + ? parseDarwinPlatformUuid(machineRaw) : /^[a-f0-9]{32}$/u.test(machineRaw) && !/^0+$/u.test(machineRaw) ? machineRaw : null @@ -266,26 +269,29 @@ export function orcadMachineDataRoot( /** * Publishes the claim exclusively and prints whichever claim won. Why a hard link: `ln` is atomic - * and exclusive on NFS too, and a reader never sees a half-written claim. A filesystem without - * hard links, or an empty claim a full disk left, falls back to an atomic (not exclusive) rename. + * and exclusive on NFS too, and a reader never sees a half-written claim. Where `ln` cannot decide + * (no hard links, or an empty claim a full disk left), a directory rename picks the winner: it is + * atomic everywhere, and the arbiter arrives already holding the winner's claim, so a crash leaves + * no half-taken lock. Every machine then republishes that same claim, so concurrent writes agree. */ export function orcadMachineClaimCommand( host: RemoteHostPlatform, loginHome: string, machineKey: string ): string { - const base = shellEscape(joinRemotePath(host, loginHome, RELAY_REMOTE_DIR)) - const claim = shellEscape( - joinRemotePath(host, loginHome, RELAY_REMOTE_DIR, ORCAD_MACHINE_CLAIM_FILENAME) - ) - const partial = shellEscape( - joinRemotePath( - host, - loginHome, - RELAY_REMOTE_DIR, - `${ORCAD_MACHINE_CLAIM_FILENAME}.partial.${randomUUID()}` - ) - ) + const remoteDir = (...parts: string[]): string => + shellEscape(joinRemotePath(host, loginHome, RELAY_REMOTE_DIR, ...parts)) + const attempt = randomUUID() + const base = remoteDir() + const claim = remoteDir(ORCAD_MACHINE_CLAIM_FILENAME) + const partial = remoteDir(`${ORCAD_MACHINE_CLAIM_FILENAME}.partial.${attempt}`) + const stageName = `${ORCAD_MACHINE_CLAIM_ARBITER_DIRNAME}.partial.${attempt}` + const stage = remoteDir(stageName) + const stageClaim = remoteDir(stageName, ORCAD_MACHINE_CLAIM_FILENAME) + const arbiter = remoteDir(ORCAD_MACHINE_CLAIM_ARBITER_DIRNAME) + const arbiterClaim = remoteDir(ORCAD_MACHINE_CLAIM_ARBITER_DIRNAME, ORCAD_MACHINE_CLAIM_FILENAME) + // `mv` into an existing arbiter nests the stage inside it instead of replacing it. + const nestedStage = remoteDir(ORCAD_MACHINE_CLAIM_ARBITER_DIRNAME, stageName) const body = shellEscape( JSON.stringify({ schemaVersion: ORCAD_MACHINE_CLAIM_SCHEMA_VERSION, @@ -295,7 +301,12 @@ export function orcadMachineClaimCommand( return [ `umask 077; mkdir -p ${base} || exit 65;`, `printf %s ${body} > ${partial} || exit 65;`, - `ln ${partial} ${claim} 2>/dev/null || [ -s ${claim} ] || mv -f ${partial} ${claim};`, - `rm -f ${partial}; cat ${claim}` + // Once an arbiter exists it decides any claim that later goes missing or empty. + `if { [ -d ${arbiter} ] || ! ln ${partial} ${claim} 2>/dev/null; } && [ ! -s ${claim} ]; then`, + `{ mkdir ${stage} && printf %s ${body} > ${stageClaim}; } || { rm -rf ${stage} ${partial}; exit 65; };`, + `mv ${stage} ${arbiter} 2>/dev/null; rm -rf ${stage} ${nestedStage};`, + `[ -s ${arbiterClaim} ] && cat ${arbiterClaim} > ${partial} || { rm -f ${partial}; exit 65; };`, + `[ -s ${claim} ] || mv -f ${partial} ${claim};`, + `fi; rm -f ${partial}; cat ${claim}` ].join(' ') } diff --git a/src/shared/runtime-host-pairing-identity.test.ts b/src/shared/runtime-host-pairing-identity.test.ts index d91c289a331..096e3e6e9cc 100644 --- a/src/shared/runtime-host-pairing-identity.test.ts +++ b/src/shared/runtime-host-pairing-identity.test.ts @@ -47,6 +47,24 @@ describe('classifyRuntimeHostRePair', () => { ).toEqual({ kind: 'same-host', pin: next, descriptor: 'moved' }) }) + it('does not call a macOS host moved when an upgrade first adds its binding, or a downgrade drops it', () => { + // Older Orca read no macOS machine id, so its descriptor carried no binding (#29259). + const unbound = { installationId: INSTALL_A } + const bound = { installationId: INSTALL_A, machineBinding: BIND_1 } + expect( + classifyRuntimeHostRePair( + { publicKeys: ['key-a'], pin: unbound }, + { publicKeyB64: 'key-a', hostDescriptor: bound } + ) + ).toEqual({ kind: 'same-host', pin: bound, descriptor: 'pinned' }) + expect( + classifyRuntimeHostRePair( + { publicKeys: ['key-a'], pin: bound }, + { publicKeyB64: 'key-a', hostDescriptor: unbound } + ) + ).toEqual({ kind: 'same-host', pin: unbound, descriptor: 'pinned' }) + }) + it('never learns from a downgraded server that stopped sending a descriptor', () => { const pin = { installationId: INSTALL_A, machineBinding: BIND_1 } expect(