mirror of
https://github.com/stablyai/orca.git
synced 2026-11-05 19:47:27 +00:00
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
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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<typeof OrcadMigrationCatalogParams>)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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<T>(
|
||||
value: unknown,
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>
|
||||
): 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')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user