mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
fix(ssh): read the macOS machine id from IOPlatformUUID and make the shared-home claim exclusive without hard links (#29259) (#29284)
This commit is contained in:
@@ -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 <class IOPlatformExpertDevice, id 0x100000253>
|
||||
{
|
||||
"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()
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 <class IOPlatformExpertDevice>\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')
|
||||
})
|
||||
|
||||
@@ -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(' ')
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user