diff --git a/src/main/ssh/orcad-migration-catalog-client.ts b/src/main/ssh/orcad-migration-catalog-client.ts index 984a881989c..f6e92d27f9f 100644 --- a/src/main/ssh/orcad-migration-catalog-client.ts +++ b/src/main/ssh/orcad-migration-catalog-client.ts @@ -26,8 +26,7 @@ import { parsePairingCode } from '../../shared/pairing' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { ORCAD_MIGRATION_CATALOG_RUNTIME_CAPABILITY } from '../../shared/orcad-runtime-capabilities' import { sendRemoteRuntimeRequestWithStatusPreflight } from '../../shared/remote-runtime-client' -import { REMOTE_RUNTIME_MAX_INBOUND_REQUEST_JSON_BYTES } from '../../shared/remote-runtime-memory-limits' -import { describeOversizedOrcadMigrationPayload } from './orcad-migration-manifest-export' +import { assertOrcadMigrationRequestFits } from './orcad-migration-manifest-export' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import { AUTOMATION_EXTRA_AGENT_ARGS_RUNTIME_CAPABILITY } from '../../shared/protocol-version' import { hasExtraAgentArgs } from '../../shared/automation-extra-agent-args' @@ -55,9 +54,6 @@ export type CatalogRequestOptions = { expectedRuntimeId?: string } -// Request id, device token, method name and JSON framing around the params. -const REQUEST_ENVELOPE_RESERVE_BYTES = 1024 - const METHOD_BY_OPERATION: Record = { abort: 'orcad.migration.abortCatalog', commit: 'orcad.migration.commitCatalog', @@ -231,18 +227,7 @@ async function request( const params = expectedBlobs.length > 0 ? { manifest, expectedBlobs } : { manifest } // A manifest under the validation cap can still overflow the host's sealed frame (more so with // expectedBlobs); the host would answer by closing the socket, so refuse before sending. - const paramsBytes = Buffer.byteLength(JSON.stringify(params), 'utf8') - if ( - paramsBytes + REQUEST_ENVELOPE_RESERVE_BYTES > - REMOTE_RUNTIME_MAX_INBOUND_REQUEST_JSON_BYTES - ) { - throw new Error( - describeOversizedOrcadMigrationPayload( - manifest.payload, - REMOTE_RUNTIME_MAX_INBOUND_REQUEST_JSON_BYTES - REQUEST_ENVELOPE_RESERVE_BYTES - ) - ) - } + assertOrcadMigrationRequestFits(params) const carriesExtraAgentArgs = (operation === 'stage' || operation === 'commit') && (manifest.payload.dormantState?.automations ?? []).some((automation) => diff --git a/src/main/ssh/orcad-migration-delta-move.test.ts b/src/main/ssh/orcad-migration-delta-move.test.ts index 41e330904c2..8c6f12e1934 100644 --- a/src/main/ssh/orcad-migration-delta-move.test.ts +++ b/src/main/ssh/orcad-migration-delta-move.test.ts @@ -31,6 +31,7 @@ import { orcadMigrationCatalogIds } from './orcad-migration-manifest-export' import { latestOrcadMigrationInto } from './orcad-migration-rollback-mark' +import { MAX_ORCAD_MIGRATION_MANIFEST_BYTES } from '../../shared/orcad-migration/orcad-migration-manifest-validation' import { retainOrcadMigrationSource } from './orcad-migration-source-retention' import { reconcileManagedOrcadSshTargets, visibleRepos } from './orcad-retained-source' import { SshConnectionStore } from './connection/ssh-connection-store' @@ -315,6 +316,25 @@ describe('moving what an older build added to a converted host', () => { expect(store.getSshTarget(TARGET.id)?.orcadFence?.sourceChangedAt).toBeUndefined() }) + it('refuses, unjournaled, a delta under the export cap that no request can carry', async () => { + await convertedThenChangedOnOlderBuild() + const baseline = JSON.stringify( + planOrcadDeltaMove(userDataPath, store, store.getSshTarget(TARGET.id)!).manifest + ).length + // Under the export cap, over the sealed-frame request budget: stage, state and abort all refuse. + store.updateRepo(store.getRepo('repo-2')!, { + displayName: 'x'.repeat(MAX_ORCAD_MIGRATION_MANIFEST_BYTES - 200 - baseline) + }) + const plan = planOrcadDeltaMove(userDataPath, store, store.getSshTarget(TARGET.id)!) + expect(JSON.stringify(plan.manifest).length).toBeLessThanOrEqual( + MAX_ORCAD_MIGRATION_MANIFEST_BYTES + ) + + await expect(deltaMove()).rejects.toThrow(/\(orcad_migration_manifest_too_large\)$/) + expect(listOrcadMigrationSourceCutovers(userDataPath)).toHaveLength(1) + expect(destination.commits).toBe(1) + }) + it('fails the whole delta when the server already holds a colliding row', async () => { await convertedThenChangedOnOlderBuild() destination.stage.mockRejectedValueOnce( diff --git a/src/main/ssh/orcad-migration-delta-move.ts b/src/main/ssh/orcad-migration-delta-move.ts index b3a10bd537d..f83392a8c52 100644 --- a/src/main/ssh/orcad-migration-delta-move.ts +++ b/src/main/ssh/orcad-migration-delta-move.ts @@ -35,6 +35,7 @@ import { unfinishedOrcadDelta, type OrcadDeltaMovePlan } from './orcad-migration-delta-plan' +import { assertOrcadMigrationRequestFits } from './orcad-migration-manifest-export' import { retainOrcadMigrationSource } from './orcad-migration-source-retention' import { assessOrcadMigrationTerminals, @@ -77,6 +78,10 @@ export async function runOrcadDeltaMove(args: OrcadDeltaMoveArgs): Promise ORCAD_MIGRATION_REQUEST_PARAMS_BUDGET_BYTES + ) { + throw new Error( + describeOversizedOrcadMigrationPayload( + params.manifest.payload, + ORCAD_MIGRATION_REQUEST_PARAMS_BUDGET_BYTES + ) + ) + } +} + /** The bare code told users nothing (#30126): name the largest parts and what to trim. */ export function describeOversizedOrcadMigrationPayload( payload: OrcadMigrationCatalogPayload, diff --git a/src/main/ssh/orcad-migration-source-fence.test.ts b/src/main/ssh/orcad-migration-source-fence.test.ts index 29c4867269b..a1bcf07f4d0 100644 --- a/src/main/ssh/orcad-migration-source-fence.test.ts +++ b/src/main/ssh/orcad-migration-source-fence.test.ts @@ -16,6 +16,8 @@ import { } from './orcad-migration-source-fence' import type { OrcadMigrationTerminalVerdict } from './orcad-migration-terminal-gate' import { SshTargetOrcadClaims } from './targets/ssh-target-orcad-claims' +import { createOrcadMigrationManifest } from './orcad-migration-manifest-export' +import { MAX_ORCAD_MIGRATION_MANIFEST_BYTES } from '../../shared/orcad-migration/orcad-migration-manifest-validation' const TARGET: SshTarget = { id: 'ssh-prod', @@ -35,7 +37,7 @@ afterEach(async () => { } }) -function setup() { +function setup(displayName = 'App') { const userDataPath = mkdtempSync(join(tmpdir(), 'orcad-migration-fence-')) directories.push(userDataPath) const dataFile = join(userDataPath, 'orca-data.json') @@ -44,7 +46,7 @@ function setup() { store.addRepo({ id: 'repo-1', path: '/srv/app', - displayName: 'App', + displayName, badgeColor: '#737373', addedAt: 1, kind: 'git', @@ -207,4 +209,21 @@ describe('migration source fence', () => { expect(listOrcadMigrationSourceCutovers(harness.userDataPath)).toEqual([]) flush.mockRestore() }) + + it('refuses, without a journal or fence, a manifest under the export cap that no request can carry', async () => { + const probe = setup() + const baseline = JSON.stringify( + createOrcadMigrationManifest(probe.store, probe.target()) + ).length + // Under the export cap, over the sealed-frame request budget: stage, state and abort all refuse. + const harness = setup('x'.repeat(MAX_ORCAD_MIGRATION_MANIFEST_BYTES - 200 - baseline)) + const manifestBytes = JSON.stringify( + createOrcadMigrationManifest(harness.store, harness.target()) + ).length + expect(manifestBytes).toBeLessThanOrEqual(MAX_ORCAD_MIGRATION_MANIFEST_BYTES) + + await expect(harness.fence()).rejects.toThrow(/\(orcad_migration_manifest_too_large\)$/) + expect(listOrcadMigrationSourceCutovers(harness.userDataPath)).toEqual([]) + expect(harness.target().orcadFence).toBeUndefined() + }) }) diff --git a/src/main/ssh/orcad-migration-source-fence.ts b/src/main/ssh/orcad-migration-source-fence.ts index 3c8cf4dc233..6e960f0b4e4 100644 --- a/src/main/ssh/orcad-migration-source-fence.ts +++ b/src/main/ssh/orcad-migration-source-fence.ts @@ -14,7 +14,10 @@ import { type OrcadMigrationSourceCutover } from '../../shared/orcad-migration/orcad-migration-source-cutover' import type { SshTarget } from '../../shared/ssh-types' -import { createOrcadMigrationManifest } from './orcad-migration-manifest-export' +import { + assertOrcadMigrationRequestFits, + createOrcadMigrationManifest +} from './orcad-migration-manifest-export' import { findOrcadMigrationSourceCutoverForTarget, removeOrcadMigrationSourceCutover, @@ -147,6 +150,8 @@ export async function fenceOrcadMigrationSource(args: { { ...target, generation }, { migrationId: randomUUID(), destinationEnvironmentId: args.destinationEnvironmentId } ) + // Before the journal: a fence on a manifest no request can carry could never be released. + assertOrcadMigrationRequestFits({ manifest }) const cutover: OrcadMigrationSourceCutover = { version: ORCAD_MIGRATION_SOURCE_CUTOVER_VERSION, migrationId: manifest.migrationId,