Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions src/main/ssh/orcad-migration-cutover-journal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ORCAD_MIGRATION_MANIFEST_VERSION } from '../../shared/orcad-migration-manifest'
import type { OrcadMigrationSourceCutover } from '../../shared/orcad-migration-source-cutover'
import { computeOrcadMigrationManifestSha256 } from '../orcad/orcad-migration-manifest-digest'
import {
findOrcadMigrationSourceCutoverForTarget,
listOrcadMigrationSourceCutovers,
orcadMigrationCutoverJournalDirectory,
removeOrcadMigrationSourceCutover,
writeOrcadMigrationSourceCutover
} from './orcad-migration-cutover-journal'

let userDataPath: string
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orcad-cutover-journal-'))
})
afterEach(() => rmSync(userDataPath, { recursive: true, force: true }))

function cutover(migrationId = 'migration-1', sshTargetId = 'ssh-1'): OrcadMigrationSourceCutover {
const unsigned = {
version: ORCAD_MIGRATION_MANIFEST_VERSION,
migrationId,
createdAt: '2026-10-01T00:00:00.000Z',
source: { sshTargetId, sshTargetGeneration: 2, targetLabel: 'Prod' },
payload: { repositories: [], projectGroups: [], folderWorkspaces: [] },
destinationEnvironmentId: 'env-1'
}
const manifest = { ...unsigned, manifestSha256: computeOrcadMigrationManifestSha256(unsigned) }
return {
version: 1,
migrationId,
phase: 'source-fenced',
startedAt: '2026-10-01T00:00:00.000Z',
updatedAt: '2026-10-01T00:00:00.000Z',
destinationEnvironmentId: 'env-1',
destinationName: 'Managed',
sshTargetId,
sshTargetGeneration: 2,
manifestSha256: manifest.manifestSha256,
manifest
}
}

const journalPath = (id: string) =>
join(orcadMigrationCutoverJournalDirectory(userDataPath), `${id}.json`)

describe('migration cutover journal sidecar', () => {
it('round-trips a cutover in an owner-only file', () => {
writeOrcadMigrationSourceCutover(userDataPath, cutover())
expect(findOrcadMigrationSourceCutoverForTarget(userDataPath, 'ssh-1')).toEqual(cutover())
if (process.platform !== 'win32') {
expect(statSync(journalPath('migration-1')).mode & 0o077).toBe(0)
}
removeOrcadMigrationSourceCutover(userDataPath, 'migration-1')
expect(listOrcadMigrationSourceCutovers(userDataPath)).toEqual([])
})

it.each([
['corrupt JSON', '{not json'],
['a manifest bound to another target', JSON.stringify({ ...cutover(), sshTargetId: 'ssh-2' })],
['a tampered manifest', JSON.stringify({ ...cutover(), manifestSha256: 'f'.repeat(64) })],
['an unknown version', JSON.stringify({ ...cutover(), version: 2 })]
])('fails closed on %s', (_label, contents) => {
mkdirSync(orcadMigrationCutoverJournalDirectory(userDataPath), { recursive: true })
writeFileSync(journalPath('migration-1'), contents)
expect(() => listOrcadMigrationSourceCutovers(userDataPath)).toThrow('stays fenced')
})

it('fails closed on a file whose name disagrees with its migration', () => {
mkdirSync(orcadMigrationCutoverJournalDirectory(userDataPath), { recursive: true })
writeFileSync(journalPath('other'), JSON.stringify(cutover()))
expect(() => listOrcadMigrationSourceCutovers(userDataPath)).toThrow('stays fenced')
})

it('ignores durable-write temporaries but refuses two journals for one target', () => {
writeOrcadMigrationSourceCutover(userDataPath, cutover())
writeFileSync(join(orcadMigrationCutoverJournalDirectory(userDataPath), 'x.json.tmp'), '?')
expect(listOrcadMigrationSourceCutovers(userDataPath)).toHaveLength(1)
writeOrcadMigrationSourceCutover(userDataPath, cutover('migration-2'))
expect(() => findOrcadMigrationSourceCutoverForTarget(userDataPath, 'ssh-1')).toThrow(
'journals name SSH target'
)
})

it('bounds concurrent migrations but rewrites an existing one', () => {
for (const index of [1, 2, 3, 4]) {
writeOrcadMigrationSourceCutover(userDataPath, cutover(`m-${index}`, `ssh-${index}`))
}
expect(() => writeOrcadMigrationSourceCutover(userDataPath, cutover('m-5', 'ssh-5'))).toThrow(
'capacity'
)
writeOrcadMigrationSourceCutover(userDataPath, {
...cutover('m-1', 'ssh-1'),
phase: 'destination-staged'
})
expect(findOrcadMigrationSourceCutoverForTarget(userDataPath, 'ssh-1')?.phase).toBe(
'destination-staged'
)
})
})
128 changes: 128 additions & 0 deletions src/main/ssh/orcad-migration-cutover-journal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* The cutover journal sidecar: one durable, owner-only file per migration beside the profile.
*
* Why not the profile store: shipped builds rewrite orca-data.json and the profile database with
* schemas that drop unknown fields, so a journal kept there would vanish across a downgrade while
* the target fence (the SSH owner, which shipped builds keep) survived it. A missing journal must
* never look like "no migration"; an unreadable one fails closed.
*/
import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { writeSecureJsonFileWithinLimit } from '../../shared/bounded-secure-json-file'
import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader'
import {
MAX_ORCAD_MIGRATION_SOURCE_CUTOVERS,
parseOrcadMigrationSourceCutover,
type OrcadMigrationSourceCutover
} from '../../shared/orcad-migration-source-cutover'
import { syncDirectoryDurablySync } from '../durable-file-write'

const JOURNAL_DIRECTORY = 'orcad-migration-cutovers'
const JOURNAL_FILE = /^[A-Za-z0-9_-]{1,128}\.json$/
// A manifest carries catalog rows and dormant session state, never scrollback bytes.
const MAX_JOURNAL_FILE_BYTES = 16 * 1024 * 1024

export class OrcadMigrationCutoverJournalUnreadableError extends Error {
constructor(detail: string) {
super(`The migration journal cannot be read, so the SSH host stays fenced: ${detail}`)
this.name = 'OrcadMigrationCutoverJournalUnreadableError'
}
}

export function orcadMigrationCutoverJournalDirectory(userDataPath: string): string {
return join(userDataPath, JOURNAL_DIRECTORY)
}

/** Every journaled cutover; throws rather than skipping a file it cannot trust. */
export function listOrcadMigrationSourceCutovers(
userDataPath: string
): OrcadMigrationSourceCutover[] {
const directory = orcadMigrationCutoverJournalDirectory(userDataPath)
if (!existsSync(directory)) {
return []
}
let names: string[]
try {
names = readdirSync(directory)
} catch (error) {
throw new OrcadMigrationCutoverJournalUnreadableError(errorMessage(error))
}
const cutovers: OrcadMigrationSourceCutover[] = []
for (const name of names) {
if (!name.endsWith('.json')) {
continue // Durable-write temporaries and foreign files carry no journal state.
}
if (!JOURNAL_FILE.test(name)) {
throw new OrcadMigrationCutoverJournalUnreadableError(`unexpected entry ${name}`)
}
cutovers.push(readJournalFile(join(directory, name), name.slice(0, -'.json'.length)))
}
return cutovers
}

export function findOrcadMigrationSourceCutoverForTarget(
userDataPath: string,
sshTargetId: string
): OrcadMigrationSourceCutover | null {
const matches = listOrcadMigrationSourceCutovers(userDataPath).filter(
(cutover) => cutover.sshTargetId === sshTargetId
)
if (matches.length > 1) {
throw new OrcadMigrationCutoverJournalUnreadableError(
`${matches.length} journals name SSH target ${sshTargetId}`
)
}
return matches[0] ?? null
}

/** Durable before it returns: a fence written after this always has its journal on disk. */
export function writeOrcadMigrationSourceCutover(
userDataPath: string,
cutover: OrcadMigrationSourceCutover
): void {
const parsed = parseOrcadMigrationSourceCutover(cutover)
const existing = listOrcadMigrationSourceCutovers(userDataPath)
if (
!existing.some((entry) => entry.migrationId === parsed.migrationId) &&
existing.length >= MAX_ORCAD_MIGRATION_SOURCE_CUTOVERS
) {
throw new Error('orcad_migration_cutover_capacity_exceeded')
}
const directory = orcadMigrationCutoverJournalDirectory(userDataPath)
mkdirSync(directory, { recursive: true, mode: 0o700 })
writeSecureJsonFileWithinLimit(
join(directory, `${parsed.migrationId}.json`),
parsed,
MAX_JOURNAL_FILE_BYTES,
{ durable: true }
)
syncDirectoryDurablySync(directory)
}

export function removeOrcadMigrationSourceCutover(userDataPath: string, migrationId: string): void {
if (!JOURNAL_FILE.test(`${migrationId}.json`)) {
throw new Error('orcad_migration_cutover_id_invalid')
}
const directory = orcadMigrationCutoverJournalDirectory(userDataPath)
rmSync(join(directory, `${migrationId}.json`), { force: true })
if (existsSync(directory)) {
syncDirectoryDurablySync(directory)
}
}

function readJournalFile(path: string, migrationId: string): OrcadMigrationSourceCutover {
try {
const raw = readNodeFileSyncWithinLimit(path, MAX_JOURNAL_FILE_BYTES).buffer.toString('utf8')
const cutover = parseOrcadMigrationSourceCutover(JSON.parse(raw))
if (cutover.migrationId !== migrationId) {
throw new Error('file name does not match its migration id')
}
return cutover
} catch (error) {
throw new OrcadMigrationCutoverJournalUnreadableError(errorMessage(error))
}
}

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
Loading
Loading