From f7235f6e2e3d59d59d5a76e2c11713844cc112e8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:44:38 -0700 Subject: [PATCH] feat(wsl-mover): accept local WSL projects on the migration wire, only in their own distro (D1b-2) (#28466) * wip(wsl-mover): local-wsl manifest source on the wire (D1b-2) * wip(wsl-mover): narrow SSH-only steps, cover WSL source parsing * feat(wsl-mover): WSL host orcad accepts its own distro's projects and advertises it (D1b-2) * fix(wsl-mover): bind a WSL source to its distro registration * fix(wsl-mover): keep the manifest parser under the line limit --- .../orcad-migration-wsl-destination.test.ts | 64 +++++++++++++++++++ .../orcad/orcad-migration-wsl-destination.ts | 40 ++++++++++++ .../orcad-source-catalog-subtraction.ts | 5 +- .../orcad-source-client-subtraction.ts | 5 +- .../orcad-source-scope.ts | 10 +-- src/main/runtime/orca-runtime-get-status.ts | 6 ++ .../runtime/rpc/methods/orcad-migration.ts | 2 + ...gration-catalog-client-source-kind.test.ts | 14 ++-- .../orcad-migration-manifest-export.test.ts | 2 +- src/shared/orcad-migration-manifest-fields.ts | 4 ++ .../orcad-migration-manifest-validation.ts | 15 ++++- src/shared/orcad-migration-manifest.test.ts | 6 +- src/shared/orcad-migration-manifest.ts | 18 +++++- src/shared/orcad-migration-source-cutover.ts | 7 +- .../orcad-migration-source-kind.test.ts | 59 +++++++++++++---- src/shared/orcad-migration-source-kind.ts | 21 ++++++ .../orcad-migration-wsl-source-validation.ts | 61 ++++++++++++++++++ 17 files changed, 306 insertions(+), 33 deletions(-) create mode 100644 src/main/orcad/orcad-migration-wsl-destination.test.ts create mode 100644 src/main/orcad/orcad-migration-wsl-destination.ts create mode 100644 src/shared/orcad-migration-wsl-source-validation.ts diff --git a/src/main/orcad/orcad-migration-wsl-destination.test.ts b/src/main/orcad/orcad-migration-wsl-destination.test.ts new file mode 100644 index 00000000000..3610dcb2153 --- /dev/null +++ b/src/main/orcad/orcad-migration-wsl-destination.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import type { OrcadMigrationManifestSource } from '../../shared/orcad-migration-manifest' +import { + assertOrcadMigrationSourceServedHere, + ORCAD_MIGRATION_SOURCE_NOT_THIS_HOST, + servesLocalWslMigrationSource +} from './orcad-migration-wsl-destination' + +const WSL_SOURCE: OrcadMigrationManifestSource = { + kind: 'local-wsl', + distro: 'Ubuntu', + scopeRepoIds: ['repo-1'], + scopeFolderWorkspaceIds: [], + generation: 1 +} +const SSH_SOURCE: OrcadMigrationManifestSource = { + sshTargetId: 'ssh-1', + sshTargetGeneration: 1, + targetLabel: 'Prod' +} +const WSL_HOST = { ORCA_WSL_ORCAD_HOST: '1', WSL_DISTRO_NAME: 'ubuntu' } + +describe('WSL migration destination', () => { + it('serves the WSL source only on the orcad Orca launched inside a distro', () => { + expect(servesLocalWslMigrationSource(WSL_HOST, 'linux')).toBe(true) + // An SSH remote or Orca under WSLg is a WSL guest too, but not a WSL host's orcad. + expect(servesLocalWslMigrationSource({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux')).toBe(false) + expect(servesLocalWslMigrationSource(WSL_HOST, 'win32')).toBe(false) + }) + + it('accepts its own distro, case-insensitively', () => { + expect(() => assertOrcadMigrationSourceServedHere(WSL_SOURCE, WSL_HOST, 'linux')).not.toThrow() + }) + + it.each([ + ['another distro', { ...WSL_HOST, WSL_DISTRO_NAME: 'Debian' }, 'linux'], + ['a plain Linux host', {}, 'linux'], + ['a desktop', WSL_HOST, 'darwin'] + ] as const)('refuses a WSL source on %s', (_label, env, platform) => { + expect(() => assertOrcadMigrationSourceServedHere(WSL_SOURCE, env, platform)).toThrow( + ORCAD_MIGRATION_SOURCE_NOT_THIS_HOST + ) + }) + + it('refuses a source from another registration of the same distro name', () => { + const source = { ...WSL_SOURCE, distroGuid: '{AAAA}' } + expect(() => + assertOrcadMigrationSourceServedHere( + source, + { ...WSL_HOST, ORCA_WSL_DISTRO_GUID: '{aaaa}' }, + 'linux' + ) + ).not.toThrow() + for (const env of [{ ...WSL_HOST, ORCA_WSL_DISTRO_GUID: '{BBBB}' }, WSL_HOST]) { + expect(() => assertOrcadMigrationSourceServedHere(source, env, 'linux')).toThrow( + ORCAD_MIGRATION_SOURCE_NOT_THIS_HOST + ) + } + }) + + it('leaves the SSH conversion alone everywhere', () => { + expect(() => assertOrcadMigrationSourceServedHere(SSH_SOURCE, {}, 'linux')).not.toThrow() + }) +}) diff --git a/src/main/orcad/orcad-migration-wsl-destination.ts b/src/main/orcad/orcad-migration-wsl-destination.ts new file mode 100644 index 00000000000..d297b1ce757 --- /dev/null +++ b/src/main/orcad/orcad-migration-wsl-destination.ts @@ -0,0 +1,40 @@ +/** + * Only the orcad an Orca desktop launched inside a distro may receive that distro's projects: the + * move keeps every file where it is, so any other host would own paths it cannot see. + */ +import type { OrcadMigrationManifestSource } from '../../shared/orcad-migration-manifest' +import { isOrcadMigrationLocalWslSource } from '../../shared/orcad-migration-source-kind' +import { isWslHostOrcadProcess } from '../ipc/wsl-guest-windows-drive-path' + +export const ORCAD_MIGRATION_SOURCE_NOT_THIS_HOST = 'orcad_migration_source_not_this_host' + +/** The distro's registry id, which the desktop passes at launch (D1b-4); the guest cannot read it. */ +export const ORCAD_WSL_DISTRO_GUID_ENV = 'ORCA_WSL_DISTRO_GUID' + +/** True on the orcad of a WSL host, which is the only host that advertises the WSL source kind. */ +export function servesLocalWslMigrationSource( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): boolean { + return isWslHostOrcadProcess(env, platform) +} + +export function assertOrcadMigrationSourceServedHere( + source: OrcadMigrationManifestSource, + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): void { + if (!isOrcadMigrationLocalWslSource(source)) { + return + } + // WSL treats distro names case-insensitively, and so does every UNC path to one. + const distro = env.WSL_DISTRO_NAME?.trim().toLowerCase() + if (!servesLocalWslMigrationSource(env, platform) || distro !== source.distro.toLowerCase()) { + throw new Error(ORCAD_MIGRATION_SOURCE_NOT_THIS_HOST) + } + // A re-registered distro reuses the name but not the files. Unknown here fails closed. + const guid = env[ORCAD_WSL_DISTRO_GUID_ENV]?.trim().toLowerCase() + if (source.distroGuid !== undefined && guid !== source.distroGuid.toLowerCase()) { + throw new Error(ORCAD_MIGRATION_SOURCE_NOT_THIS_HOST) + } +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog-subtraction.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog-subtraction.ts index 3322494e17a..7ddd4eb3adf 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog-subtraction.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog-subtraction.ts @@ -5,6 +5,7 @@ */ import type { StoreRuntimeState } from '../loading-store/store-runtime-state' import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' +import { requireOrcadMigrationSshSource } from '../../../shared/orcad-migration-source-kind' import { orcadSourceFolderWorkspaceIds, repoBelongsToOrcadSource } from './orcad-source-ownership' export function subtractOrcadSourceCatalogState( @@ -14,7 +15,7 @@ export function subtractOrcadSourceCatalogState( const repoIds = new Set(manifest.payload.repositories.map((repo) => repo.id)) const folderIds = new Set(manifest.payload.folderWorkspaces.map((workspace) => workspace.id)) const groupIds = new Set(manifest.payload.projectGroups.map((group) => group.id)) - const targetId = manifest.source.sshTargetId + const targetId = requireOrcadMigrationSshSource(manifest.source).sshTargetId // Before any repo goes: a folder owned through the repos inside it would read as local after. const ownedFolderIds = orcadSourceFolderWorkspaceIds(state, targetId) state.repos = state.repos.filter( @@ -23,7 +24,7 @@ export function subtractOrcadSourceCatalogState( state.folderWorkspaces = state.folderWorkspaces.filter( (workspace) => !(folderIds.has(workspace.id) && ownedFolderIds.has(workspace.id)) ) - removeUnreferencedSourceGroups(state, groupIds, manifest.source.sshTargetId) + removeUnreferencedSourceGroups(state, groupIds, targetId) } function removeUnreferencedSourceGroups( diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-subtraction.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-subtraction.ts index d195255fe8d..4bdd2c95b78 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-subtraction.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-subtraction.ts @@ -1,5 +1,6 @@ import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' import { serializeOrcadMigrationValue } from '../../../shared/orcad-migration-manifest' +import { requireOrcadMigrationSshSource } from '../../../shared/orcad-migration-source-kind' import { parsePersistedAutomationHostFilter } from '../../../shared/automation-host-filter' import { hostStableKey } from '../../../shared/automation-owner-key' import { toRuntimeExecutionHostId } from '../../../shared/execution-host' @@ -47,7 +48,9 @@ export function subtractOrcadMigrationClientState( } } } - const target = state.sshTargets.find((entry) => entry.id === source.sshTargetId) + const target = state.sshTargets.find( + (entry) => entry.id === requireOrcadMigrationSshSource(source).sshTargetId + ) if (target && clientState.savedPortForwards) { target.portForwards = structuredClone(clientState.savedPortForwards) } diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts index 8af68c213e2..05918a10b4a 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts @@ -1,3 +1,4 @@ +import { requireOrcadMigrationSshSource } from '../../../shared/orcad-migration-source-kind' import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../shared/execution-host' import type { OrcadMigrationCatalogPayload, @@ -43,10 +44,11 @@ export function createOrcadMigrationSourceScope(args: { repos: readonly Repo[] }): OrcadMigrationSourceScope { const repoIds = new Set(args.catalog.repositories.map((repo) => repo.id)) + const source = requireOrcadMigrationSshSource(args.source) return { - targetId: args.source.sshTargetId, - targetGeneration: args.source.sshTargetGeneration, - hostId: toSshExecutionHostId(args.source.sshTargetId), + targetId: source.sshTargetId, + targetGeneration: source.sshTargetGeneration, + hostId: toSshExecutionHostId(source.sshTargetId), repoIds, folderWorkspaceKeys: new Set( args.catalog.folderWorkspaces.map((workspace) => `folder:${workspace.id}`) @@ -54,7 +56,7 @@ export function createOrcadMigrationSourceScope(args: { sharedRepoIds: new Set( args.repos .filter( - (repo) => repoIds.has(repo.id) && !repoBelongsToOrcadSource(repo, args.source.sshTargetId) + (repo) => repoIds.has(repo.id) && !repoBelongsToOrcadSource(repo, source.sshTargetId) ) .map((repo) => repo.id) ) diff --git a/src/main/runtime/orca-runtime-get-status.ts b/src/main/runtime/orca-runtime-get-status.ts index 070e320871d..95c3cc5a714 100644 --- a/src/main/runtime/orca-runtime-get-status.ts +++ b/src/main/runtime/orca-runtime-get-status.ts @@ -6,6 +6,8 @@ import { runtimeBrowserUnavailableCause } from './runtime-browser-commands-factory' import { isBrowserIdentityModeStoreInitialized } from '../browser/browser-identity-mode-store' +import { servesLocalWslMigrationSource } from '../orcad/orcad-migration-wsl-destination' +import { ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY } from '../../shared/orcad-runtime-capabilities' import type { RuntimeCapability } from '../../shared/protocol-version' import { BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY, @@ -117,6 +119,10 @@ export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId { if (isBrowserIdentityModeStoreInitialized()) { capabilities.push(BROWSER_IDENTITY_RUNTIME_CAPABILITY) } + // Why not static: only the orcad inside a distro can receive that distro's projects. + if (servesLocalWslMigrationSource()) { + capabilities.push(ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY) + } // Why: certificate proceed is owned by the browser-hosting process for both // desktop webviews and offscreen pages. Advertise whenever either backend // can host a page so remote clients can surface Proceed Anyway (Unsafe). diff --git a/src/main/runtime/rpc/methods/orcad-migration.ts b/src/main/runtime/rpc/methods/orcad-migration.ts index be09836eb4e..4aee8bcb4a5 100644 --- a/src/main/runtime/rpc/methods/orcad-migration.ts +++ b/src/main/runtime/rpc/methods/orcad-migration.ts @@ -3,6 +3,7 @@ import { OrcadMigrationCatalogParams } from '../../../../shared/rpc-contract/orc import { parseOrcadMigrationManifest } from '../../../../shared/orcad-migration-manifest' import { OrcadMigrationSnapshotChunkRequestSchema } from '../../../../shared/orcad-migration-scrollback' import { assertOrcadMigrationManifestDigest } from '../../../orcad/orcad-migration-manifest-digest' +import { assertOrcadMigrationSourceServedHere } from '../../../orcad/orcad-migration-wsl-destination' import { defineMethod, type RpcContext } from '../core' export const ORCAD_MIGRATION_METHODS = [ @@ -63,6 +64,7 @@ function migrationManifest(params: z.infer) const manifest = parseOrcadMigrationManifest(params.manifest) // Raw, not parsed: parsing drops fields this host predates, which a newer client signed. assertOrcadMigrationManifestDigest(params.manifest) + assertOrcadMigrationSourceServedHere(manifest.source) return manifest } diff --git a/src/main/ssh/orcad-migration-catalog-client-source-kind.test.ts b/src/main/ssh/orcad-migration-catalog-client-source-kind.test.ts index 5934de8b90e..528656adbec 100644 --- a/src/main/ssh/orcad-migration-catalog-client-source-kind.test.ts +++ b/src/main/ssh/orcad-migration-catalog-client-source-kind.test.ts @@ -56,11 +56,17 @@ function sshManifest(): OrcadMigrationManifest { return { ...unsigned, manifestSha256: computeOrcadMigrationManifestSha256(unsigned) } } -/** A future kind as the wire carries it; this build's types know only the SSH source. */ +/** A WSL source, or any other `kind` as a newer or confused peer could put on the wire. */ function manifestOfKind(kind: string): OrcadMigrationManifest { - const base = sshManifest() - const source = { ...base.source, kind } - return { ...base, source } + const source: OrcadMigrationManifest['source'] = { + kind: 'local-wsl', + distro: 'Ubuntu', + scopeRepoIds: [], + scopeFolderWorkspaceIds: [], + generation: 1 + } + Object.assign(source, { kind }) + return { ...sshManifest(), source } } const pairingCode = encodePairingOffer({ diff --git a/src/main/ssh/orcad-migration-manifest-export.test.ts b/src/main/ssh/orcad-migration-manifest-export.test.ts index 8637dca12b2..394483bf635 100644 --- a/src/main/ssh/orcad-migration-manifest-export.test.ts +++ b/src/main/ssh/orcad-migration-manifest-export.test.ts @@ -127,7 +127,7 @@ describe('orcad migration manifest export', () => { { migrationId: 'migration-legacy', now: () => new Date('2026-08-30T12:00:00.000Z') } ) - expect(manifest.source.sshTargetGeneration).toBeNull() + expect(manifest.source).toMatchObject({ sshTargetGeneration: null }) expect(target.generation).toBeUndefined() }) diff --git a/src/shared/orcad-migration-manifest-fields.ts b/src/shared/orcad-migration-manifest-fields.ts index fd93eea29d5..5e5bc2b34ae 100644 --- a/src/shared/orcad-migration-manifest-fields.ts +++ b/src/shared/orcad-migration-manifest-fields.ts @@ -1,5 +1,9 @@ // Field checks shared by the manifest and receipt parsers; each throws a labeled error. +/** Catalog bounds, shared by the manifest parser and the WSL source's scope lists. */ +export const MAX_ORCAD_MIGRATION_REPOSITORIES = 1_024 +export const MAX_ORCAD_MIGRATION_FOLDER_WORKSPACES = 16_384 + /** The one bounded-array check; `errorPrefix` names the manifest or dormant error family. */ export function boundedList( value: unknown, diff --git a/src/shared/orcad-migration-manifest-validation.ts b/src/shared/orcad-migration-manifest-validation.ts index eaf4c912707..ba20b2934d6 100644 --- a/src/shared/orcad-migration-manifest-validation.ts +++ b/src/shared/orcad-migration-manifest-validation.ts @@ -5,9 +5,15 @@ import type { OrcadMigrationManifest, OrcadMigrationManifestSource } from './orcad-migration-manifest' +import { + assertLocalWslScopeMatchesPayload, + parseLocalWslSource +} from './orcad-migration-wsl-source-validation' import type { ProjectGroup } from './project-group-types' import type { Repo } from './repo-types' import { + MAX_ORCAD_MIGRATION_FOLDER_WORKSPACES, + MAX_ORCAD_MIGRATION_REPOSITORIES, assertUniqueIds, boundedArray, boundedStringArray, @@ -21,12 +27,11 @@ import { parseOrcadMigrationDormantState } from './orcad-migration-dormant-state-validation' +export { MAX_ORCAD_MIGRATION_FOLDER_WORKSPACES, MAX_ORCAD_MIGRATION_REPOSITORIES } export const ORCAD_MIGRATION_MANIFEST_VERSION = 1 as const export type OrcadMigrationManifestVersion = typeof ORCAD_MIGRATION_MANIFEST_VERSION export const MAX_ORCAD_MIGRATION_MANIFEST_BYTES = 768 * 1024 -export const MAX_ORCAD_MIGRATION_REPOSITORIES = 1_024 export const MAX_ORCAD_MIGRATION_PROJECT_GROUPS = 4_096 -export const MAX_ORCAD_MIGRATION_FOLDER_WORKSPACES = 16_384 export const MAX_ORCAD_MIGRATION_IMPORT_RECEIPTS = 64 export function parseOrcadMigrationManifest(value: unknown): OrcadMigrationManifest { @@ -64,6 +69,7 @@ export function parseOrcadMigrationManifest(value: unknown): OrcadMigrationManif if (bytes > MAX_ORCAD_MIGRATION_MANIFEST_BYTES) { throw new Error('orcad_migration_manifest_too_large') } + assertLocalWslScopeMatchesPayload(manifest) return manifest } @@ -144,7 +150,10 @@ function parseSource(value: unknown): OrcadMigrationManifestSource { if (!isRecord(value)) { throw new Error('orcad_migration_manifest_source_invalid') } - // Only the relay-era SSH source (no `kind`) is parsed here; other kinds ship with their capability. + if (value.kind === 'local-wsl') { + return parseLocalWslSource(value) + } + // An explicit 'ssh-relay' is refused too: SSH has exactly one encoding, so one digest. if (value.kind !== undefined) { throw new Error('orcad_migration_manifest_source_kind_unsupported') } diff --git a/src/shared/orcad-migration-manifest.test.ts b/src/shared/orcad-migration-manifest.test.ts index a5512dad1d5..8f14200a2fe 100644 --- a/src/shared/orcad-migration-manifest.test.ts +++ b/src/shared/orcad-migration-manifest.test.ts @@ -49,9 +49,9 @@ describe('orcad migration manifest', () => { expect(normalized).toHaveLength(MAX_ORCAD_MIGRATION_IMPORT_RECEIPTS) expect(normalized.some((entry) => entry.migrationId === 'migration-0')).toBe(false) - expect( - normalized.find((entry) => entry.migrationId === 'migration-10')?.source.targetLabel - ).toBe('Newest') + expect(normalized.find((entry) => entry.migrationId === 'migration-10')?.source).toMatchObject({ + targetLabel: 'Newest' + }) }) it('normalizes receipts without Node 20-only array methods', () => { diff --git a/src/shared/orcad-migration-manifest.ts b/src/shared/orcad-migration-manifest.ts index bed98017e47..3613b17200e 100644 --- a/src/shared/orcad-migration-manifest.ts +++ b/src/shared/orcad-migration-manifest.ts @@ -42,7 +42,23 @@ export type OrcadMigrationSshManifestSource = { targetLabel: string } -export type OrcadMigrationManifestSource = OrcadMigrationSshManifestSource +/** + * Local WSL projects on this desktop, moving to the orcad that runs inside their distro. Scope is + * explicit so a destination never infers it from paths, and `generation` orders delta moves. + */ +export type OrcadMigrationLocalWslManifestSource = { + kind: 'local-wsl' + distro: string + /** The distro's registry id, when known: a re-registered distro of the same name is another one. */ + distroGuid?: string + scopeRepoIds: string[] + scopeFolderWorkspaceIds: string[] + generation: number +} + +export type OrcadMigrationManifestSource = + | OrcadMigrationSshManifestSource + | OrcadMigrationLocalWslManifestSource export type OrcadMigrationCatalogPayload = { repositories: Repo[] diff --git a/src/shared/orcad-migration-source-cutover.ts b/src/shared/orcad-migration-source-cutover.ts index 51c24475d8c..39e2c4136f9 100644 --- a/src/shared/orcad-migration-source-cutover.ts +++ b/src/shared/orcad-migration-source-cutover.ts @@ -7,6 +7,7 @@ import { parseOrcadMigrationManifest, type OrcadMigrationManifest } from './orcad-migration-manifest' +import { isOrcadMigrationLocalWslSource } from './orcad-migration-source-kind' export const ORCAD_MIGRATION_SOURCE_CUTOVER_VERSION = 1 export const MAX_ORCAD_MIGRATION_SOURCE_CUTOVERS = 4 @@ -64,11 +65,13 @@ export function isRetainedOrcadMigrationSourceCutover( export function parseOrcadMigrationSourceCutover(value: unknown): OrcadMigrationSourceCutover { const record = CutoverRecordSchema.parse(value) const manifest = parseOrcadMigrationManifest(record.manifest) + // An SSH journal binds an SSH manifest; a WSL manifest here is a mismatch, never a fallback. + const source = isOrcadMigrationLocalWslSource(manifest.source) ? null : manifest.source if ( manifest.migrationId !== record.migrationId || manifest.manifestSha256 !== record.manifestSha256 || - manifest.source.sshTargetId !== record.sshTargetId || - manifest.source.sshTargetGeneration !== record.sshTargetGeneration || + source?.sshTargetId !== record.sshTargetId || + source.sshTargetGeneration !== record.sshTargetGeneration || manifest.destinationEnvironmentId !== record.destinationEnvironmentId ) { throw new Error('orcad_migration_cutover_binding_mismatch') diff --git a/src/shared/orcad-migration-source-kind.test.ts b/src/shared/orcad-migration-source-kind.test.ts index d4010cc10e7..5b9acc34450 100644 --- a/src/shared/orcad-migration-source-kind.test.ts +++ b/src/shared/orcad-migration-source-kind.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { isRecord } from './orcad-migration-manifest-fields' import { ORCAD_MIGRATION_MANIFEST_VERSION, normalizeOrcadMigrationImportReceipts, @@ -15,6 +16,20 @@ import { } from './orcad-runtime-capabilities' const SSH_SOURCE = { sshTargetId: 'ssh-1', sshTargetGeneration: 2, targetLabel: 'Prod' } +const WSL_SOURCE = { + kind: 'local-wsl', + distro: 'Ubuntu', + scopeRepoIds: ['repo-1'], + scopeFolderWorkspaceIds: [], + generation: 1 +} +const WSL_REPO = { + id: 'repo-1', + path: '/home/dev/app', + displayName: 'app', + badgeColor: '#000', + addedAt: 0 +} /** Exactly what a desktop that predates `source.kind` sends. */ function manifestWithSource(source: unknown) { @@ -23,7 +38,11 @@ function manifestWithSource(source: unknown) { migrationId: 'migration-kind', createdAt: '2026-10-01T00:00:00.000Z', source, - payload: { repositories: [], projectGroups: [], folderWorkspaces: [] }, + payload: { + repositories: isRecord(source) && source.kind === 'local-wsl' ? [WSL_REPO] : [], + projectGroups: [], + folderWorkspaces: [] + }, manifestSha256: 'a'.repeat(64) } } @@ -35,7 +54,7 @@ describe('orcad migration source kind', () => { expect(requiredOrcadMigrationSourceCapability(SSH_SOURCE)).toBeNull() }) - it('gates a WSL source on its own capability, which no destination advertises yet', () => { + it('gates a WSL source on its own capability, which only a WSL host advertises', () => { expect(requiredOrcadMigrationSourceCapability({ kind: 'local-wsl' })).toBe( ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY ) @@ -67,16 +86,32 @@ describe('orcad migration manifest source on the destination', () => { expect(normalizeOrcadMigrationImportReceipts([receipt])).toEqual([receipt]) }) - it('refuses any source kind until it ships the parser for that kind', () => { - for (const source of [ - { ...SSH_SOURCE, kind: 'local-wsl' }, - { kind: 'local-wsl', distro: 'Ubuntu', targetLabel: 'Ubuntu' }, - { ...SSH_SOURCE, kind: 'ssh-relay' } - ]) { - expect(() => parseOrcadMigrationManifest(manifestWithSource(source))).toThrow( - 'orcad_migration_manifest_source_kind_unsupported' - ) - } + it('refuses an explicit SSH kind, and a WSL source carrying SSH fields', () => { + expect(() => + parseOrcadMigrationManifest(manifestWithSource({ ...SSH_SOURCE, kind: 'ssh-relay' })) + ).toThrow('orcad_migration_manifest_source_kind_unsupported') + expect(() => + parseOrcadMigrationManifest(manifestWithSource({ ...WSL_SOURCE, sshTargetId: 'ssh-1' })) + ).toThrow('orcad_migration_manifest_source_invalid') + }) + + it('parses a WSL source whose scope is exactly the catalog it moves', () => { + const manifest = parseOrcadMigrationManifest( + manifestWithSource({ ...WSL_SOURCE, distroGuid: '{guid}' }) + ) + expect(manifest.source).toEqual({ ...WSL_SOURCE, distroGuid: '{guid}' }) + }) + + it.each([ + { scopeRepoIds: ['repo-other'] }, + { scopeRepoIds: [] }, + { scopeFolderWorkspaceIds: ['folder-1'] }, + { generation: 0 }, + { distro: '' } + ])('refuses a WSL source that does not describe its catalog: %j', (override) => { + expect(() => + parseOrcadMigrationManifest(manifestWithSource({ ...WSL_SOURCE, ...override })) + ).toThrow(/source/) }) it('fails a WSL source closed on a destination that drops the kind', () => { diff --git a/src/shared/orcad-migration-source-kind.ts b/src/shared/orcad-migration-source-kind.ts index 42b669647fd..3a36262dfc4 100644 --- a/src/shared/orcad-migration-source-kind.ts +++ b/src/shared/orcad-migration-source-kind.ts @@ -6,6 +6,11 @@ * sets `kind` and must never carry `sshTargetId`: an older destination strips unknown fields, so a * non-SSH source without it fails that destination's parse instead of importing as an SSH host. */ +import type { + OrcadMigrationLocalWslManifestSource, + OrcadMigrationManifestSource, + OrcadMigrationSshManifestSource +} from './orcad-migration-manifest' import { ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY } from './orcad-runtime-capabilities' export const ORCAD_MIGRATION_SSH_RELAY_SOURCE_KIND = 'ssh-relay' as const @@ -52,3 +57,19 @@ export function requiredOrcadMigrationSourceCapability( ? null : ORCAD_MIGRATION_SOURCE_KIND_CAPABILITIES[kind] } + +export function isOrcadMigrationLocalWslSource( + source: OrcadMigrationManifestSource +): source is OrcadMigrationLocalWslManifestSource { + return 'kind' in source && source.kind === 'local-wsl' +} + +/** For the SSH conversion's own steps: a WSL manifest reaching them is a bug, never a fallback. */ +export function requireOrcadMigrationSshSource( + source: OrcadMigrationManifestSource +): OrcadMigrationSshManifestSource { + if (isOrcadMigrationLocalWslSource(source)) { + throw new Error('orcad_migration_source_not_ssh') + } + return source +} diff --git a/src/shared/orcad-migration-wsl-source-validation.ts b/src/shared/orcad-migration-wsl-source-validation.ts new file mode 100644 index 00000000000..79e7ada4bda --- /dev/null +++ b/src/shared/orcad-migration-wsl-source-validation.ts @@ -0,0 +1,61 @@ +/** Parsing for the `local-wsl` manifest source: a desktop's own WSL projects, moving into their distro. */ +import type { + OrcadMigrationLocalWslManifestSource, + OrcadMigrationManifest +} from './orcad-migration-manifest' +import { + MAX_ORCAD_MIGRATION_FOLDER_WORKSPACES, + MAX_ORCAD_MIGRATION_REPOSITORIES, + boundedStringArray, + requiredString +} from './orcad-migration-manifest-fields' +import { isOrcadMigrationLocalWslSource } from './orcad-migration-source-kind' + +export function parseLocalWslSource( + value: Record +): OrcadMigrationLocalWslManifestSource { + // A non-SSH source naming an SSH target could only be a confused or forged manifest. + if (value.sshTargetId !== undefined) { + throw new Error('orcad_migration_manifest_source_invalid') + } + const generation = value.generation + if (!Number.isSafeInteger(generation) || Number(generation) < 1) { + throw new Error('orcad_migration_manifest_source_generation_invalid') + } + return { + kind: 'local-wsl', + distro: requiredString(value.distro, 'source.distro'), + ...(value.distroGuid === undefined + ? {} + : { distroGuid: requiredString(value.distroGuid, 'source.distroGuid') }), + scopeRepoIds: boundedStringArray( + value.scopeRepoIds, + MAX_ORCAD_MIGRATION_REPOSITORIES, + 'source.scopeRepoIds_invalid' + ), + scopeFolderWorkspaceIds: boundedStringArray( + value.scopeFolderWorkspaceIds, + MAX_ORCAD_MIGRATION_FOLDER_WORKSPACES, + 'source.scopeFolderWorkspaceIds_invalid' + ), + generation: Number(generation) + } +} + +/** The catalog a WSL source moves is exactly its declared scope; the host never infers scope. */ +export function assertLocalWslScopeMatchesPayload(manifest: OrcadMigrationManifest): void { + const { source, payload } = manifest + if (!isOrcadMigrationLocalWslSource(source)) { + return + } + const sameIds = (declared: readonly string[], rows: readonly { id: string }[]): boolean => { + const ids = new Set(rows.map((row) => row.id)) + return declared.length === ids.size && declared.every((id) => ids.has(id)) + } + if ( + !sameIds(source.scopeRepoIds, payload.repositories) || + !sameIds(source.scopeFolderWorkspaceIds, payload.folderWorkspaces) + ) { + throw new Error('orcad_migration_manifest_source_scope_mismatch') + } +}