diff --git a/src/main/orcad/orcad-health.test.ts b/src/main/orcad/orcad-health.test.ts index 70e02b37e87..f21ea22f92e 100644 --- a/src/main/orcad/orcad-health.test.ts +++ b/src/main/orcad/orcad-health.test.ts @@ -1,4 +1,6 @@ -import { mkdtempSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { computeLocalOrcadBuildHash } from '../ssh/orcad-local-build-hash' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -172,3 +174,48 @@ describe('computeOrcadBuildHash', () => { expect(computeOrcadBuildHash('')).toBe('unknown') }) }) + +describe('shared orcad build identity', () => { + let directory: string + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'orcad-build-identity-')) + }) + afterEach(() => rmSync(directory, { recursive: true, force: true })) + + it('changes matching host and client hashes when only the application changes', () => { + const entry = join(directory, 'orcad.js') + writeFileSync(entry, 'import("./orcad-app")') + writeFileSync(join(directory, 'orcad-app.js'), 'application-a') + const first = computeLocalOrcadBuildHash(directory) + expect(computeOrcadBuildHash(entry)).toBe(first) + writeFileSync(join(directory, 'orcad-app.js'), 'application-b') + const second = computeLocalOrcadBuildHash(directory) + expect(second).not.toBe(first) + expect(computeOrcadBuildHash(entry)).toBe(second) + writeFileSync(entry, '// launcher changed\nimport("./orcad-app")') + expect(computeLocalOrcadBuildHash(directory)).not.toBe(second) + expect(computeOrcadBuildHash(entry)).toBe(computeLocalOrcadBuildHash(directory)) + }) + + it('preserves the original single-bundle hash for older deployments', () => { + const entry = join(directory, 'orcad.js') + const legacy = 'original single-bundle application' + writeFileSync(entry, legacy) + const expected = createHash('sha256').update(legacy).digest('hex').slice(0, 16) + expect(computeLocalOrcadBuildHash(directory)).toBe(expected) + expect(computeOrcadBuildHash(entry)).toBe(expected) + }) + + it.each(['missing', 'unreadable'])( + 'refuses a %s application instead of hashing only the launcher', + (kind) => { + const entry = join(directory, 'orcad.js') + writeFileSync(entry, 'import("./orcad-app")') + if (kind === 'unreadable') { + mkdirSync(join(directory, 'orcad-app.js')) + } + expect(() => computeLocalOrcadBuildHash(directory)).toThrow() + expect(computeOrcadBuildHash(entry)).toBe('unknown') + } + ) +}) diff --git a/src/main/orcad/orcad-health.ts b/src/main/orcad/orcad-health.ts index 0c3a3525171..2517d4f37fa 100644 --- a/src/main/orcad/orcad-health.ts +++ b/src/main/orcad/orcad-health.ts @@ -8,8 +8,7 @@ * to cross the process boundary: orcad drives it, the daemon performs it, and the verdict * travels back over the daemon's socket. */ -import { createHash } from 'node:crypto' -import { readFileSync } from 'node:fs' +import { readOrcadBuildHash } from '../../shared/orcad-build-hash' import process from 'node:process' import { checkDaemonHealth, type DaemonHealth } from '../daemon/daemon-health' import { @@ -81,7 +80,7 @@ export function computeOrcadBuildHash(entryPath = process.argv[1]): string { return 'unknown' } try { - return createHash('sha256').update(readFileSync(entryPath)).digest('hex').slice(0, 16) + return readOrcadBuildHash(entryPath) } catch { return 'unknown' } diff --git a/src/main/ssh/orcad-local-build-hash.ts b/src/main/ssh/orcad-local-build-hash.ts index 3740ea6d55c..6de7dc211be 100644 --- a/src/main/ssh/orcad-local-build-hash.ts +++ b/src/main/ssh/orcad-local-build-hash.ts @@ -1,22 +1,9 @@ -/** - * The build identity the deploy expects the host to answer with. - * - * It must be computed the same way `computeOrcadBuildHash` computes it on the host — - * sha256 of `orcad.js`, first 16 hex characters — or the activation gate would reject every - * healthy candidate. Keeping the two in one comment is deliberate: they are one contract - * split across a network, and the version string cannot stand in for it, because - * `ORCA_VERSION` is whatever the launch command exported and two builds can carry one value. - */ -import { createHash } from 'node:crypto' -import { readFileSync } from 'node:fs' import { join } from 'node:path' +import { readOrcadBuildHash } from '../../shared/orcad-build-hash' -export const ORCAD_BUILD_HASH_LENGTH = 16 +export { ORCAD_BUILD_HASH_LENGTH } from '../../shared/orcad-build-hash' +/** The activation gate compares this with the host's hash of the same installed bytes. */ export function computeLocalOrcadBuildHash(localOrcadDir: string): string { - const entry = join(localOrcadDir, 'orcad.js') - return createHash('sha256') - .update(readFileSync(entry)) - .digest('hex') - .slice(0, ORCAD_BUILD_HASH_LENGTH) + return readOrcadBuildHash(join(localOrcadDir, 'orcad.js')) } diff --git a/src/shared/orcad-build-hash.ts b/src/shared/orcad-build-hash.ts new file mode 100644 index 00000000000..1e8d50ad74a --- /dev/null +++ b/src/shared/orcad-build-hash.ts @@ -0,0 +1,32 @@ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' + +export const ORCAD_BUILD_HASH_LENGTH = 16 +const APPLICATION_FILENAME = 'orcad-app.js' + +/** Keep legacy identities stable; split builds must identify the application as well. */ +export function readOrcadBuildHash(entryPath: string): string { + const entry = readFileSync(entryPath) + let application: Buffer + try { + application = readFileSync(join(dirname(entryPath), APPLICATION_FILENAME)) + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' && + !entry.includes('orcad-app') + ) { + return createHash('sha256').update(entry).digest('hex').slice(0, ORCAD_BUILD_HASH_LENGTH) + } + throw error + } + return createHash('sha256') + .update(`orcad-split-build\0${entry.length}\0`) + .update(entry) + .update(`\0${application.length}\0`) + .update(application) + .digest('hex') + .slice(0, ORCAD_BUILD_HASH_LENGTH) +}