From 3eeea7bdb8b10825416d7f9bac03d5a820a997f5 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 14 Jul 2026 22:53:48 -0400 Subject: [PATCH] build(ssh): apply native signing returns safely Co-authored-by: Orca --- .../workflows/ssh-relay-runtime-artifacts.yml | 8 + ...ssh-relay-runtime-native-signing-apply.mjs | 206 +++++++++++ ...elay-runtime-native-signing-apply.test.mjs | 322 ++++++++++++++++++ .../ssh-relay-runtime-workflow.test.mjs | 4 + 4 files changed, 540 insertions(+) create mode 100644 config/scripts/ssh-relay-runtime-native-signing-apply.mjs create mode 100644 config/scripts/ssh-relay-runtime-native-signing-apply.test.mjs diff --git a/.github/workflows/ssh-relay-runtime-artifacts.yml b/.github/workflows/ssh-relay-runtime-artifacts.yml index e12a73b799f..79c0088adfa 100644 --- a/.github/workflows/ssh-relay-runtime-artifacts.yml +++ b/.github/workflows/ssh-relay-runtime-artifacts.yml @@ -135,6 +135,8 @@ jobs: node --check config/scripts/ssh-relay-runtime-windows-authenticode-assessment.test.mjs node --check config/scripts/ssh-relay-runtime-native-signing-stage.mjs node --check config/scripts/ssh-relay-runtime-native-signing-stage.test.mjs + node --check config/scripts/ssh-relay-runtime-native-signing-apply.mjs + node --check config/scripts/ssh-relay-runtime-native-signing-apply.test.mjs pnpm exec vitest run --config config/vitest.config.ts \ config/scripts/ssh-relay-node-release-verification.test.mjs \ config/scripts/ssh-relay-node-tar-inspection.test.mjs \ @@ -152,6 +154,7 @@ jobs: config/scripts/ssh-relay-runtime-native-signing-payload.test.mjs \ config/scripts/ssh-relay-runtime-windows-authenticode-assessment.test.mjs \ config/scripts/ssh-relay-runtime-native-signing-stage.test.mjs \ + config/scripts/ssh-relay-runtime-native-signing-apply.test.mjs \ config/scripts/ssh-relay-runtime-closure.test.mjs \ config/scripts/ssh-relay-runtime-pty-smoke.test.mjs \ config/scripts/ssh-relay-runtime-reproducibility.test.mjs \ @@ -451,6 +454,10 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'runtime native signing stage script syntax check failed' } node --check config/scripts/ssh-relay-runtime-native-signing-stage.test.mjs if ($LASTEXITCODE -ne 0) { throw 'runtime native signing stage test syntax check failed' } + node --check config/scripts/ssh-relay-runtime-native-signing-apply.mjs + if ($LASTEXITCODE -ne 0) { throw 'runtime native signing apply script syntax check failed' } + node --check config/scripts/ssh-relay-runtime-native-signing-apply.test.mjs + if ($LASTEXITCODE -ne 0) { throw 'runtime native signing apply test syntax check failed' } pnpm exec vitest run --config config/vitest.config.ts ` config/scripts/ssh-relay-node-release-verification.test.mjs ` config/scripts/ssh-relay-node-tar-inspection.test.mjs ` @@ -468,6 +475,7 @@ jobs: config/scripts/ssh-relay-runtime-native-signing-payload.test.mjs ` config/scripts/ssh-relay-runtime-windows-authenticode-assessment.test.mjs ` config/scripts/ssh-relay-runtime-native-signing-stage.test.mjs ` + config/scripts/ssh-relay-runtime-native-signing-apply.test.mjs ` config/scripts/ssh-relay-runtime-closure.test.mjs ` config/scripts/ssh-relay-runtime-pty-smoke.test.mjs ` config/scripts/ssh-relay-runtime-reproducibility.test.mjs ` diff --git a/config/scripts/ssh-relay-runtime-native-signing-apply.mjs b/config/scripts/ssh-relay-runtime-native-signing-apply.mjs new file mode 100644 index 00000000000..ae061635548 --- /dev/null +++ b/config/scripts/ssh-relay-runtime-native-signing-apply.mjs @@ -0,0 +1,206 @@ +import { constants } from 'node:fs' +import { chmod, copyFile, lstat, mkdir, realpath, rm } from 'node:fs/promises' +import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path' +import { isDeepStrictEqual } from 'node:util' + +import { assertSshRelayRuntimeClosureEntries } from './ssh-relay-runtime-closure.mjs' +import { computeSshRelayRuntimeContentId } from './ssh-relay-runtime-identity.mjs' +import { verifySshRelayRuntimeNativeSigningReturn } from './ssh-relay-runtime-native-signing-payload.mjs' +import { buildSshRelayRuntimeNativeSigningSelection } from './ssh-relay-runtime-native-signing-selection.mjs' +import { verifyRuntimeTree } from './verify-ssh-relay-runtime.mjs' + +function containsPath(parent, candidate) { + const path = relative(parent, candidate) + return path === '' || (path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path)) +} + +async function physicalDirectory(path, label) { + const metadata = await lstat(path) + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new Error(`Runtime native signing ${label} must be a real directory`) + } + return realpath(path) +} + +async function assertAbsent(path) { + try { + await lstat(path) + } catch (error) { + if (error.code === 'ENOENT') { + return + } + throw error + } + throw new Error('Runtime native signing apply requires an exclusive output root') +} + +export async function assertSshRelayRuntimeNativeSigningApplyRoots({ + sourceRuntimeRoot, + returnedRoot, + outputRuntimeRoot +}) { + const absoluteOutput = resolve(outputRuntimeRoot) + const [physicalSource, physicalReturned, physicalOutputParent] = await Promise.all([ + physicalDirectory(resolve(sourceRuntimeRoot), 'source root'), + physicalDirectory(resolve(returnedRoot), 'returned root'), + physicalDirectory(dirname(absoluteOutput), 'output parent') + ]) + const physicalOutput = resolve(physicalOutputParent, basename(absoluteOutput)) + const roots = [physicalSource, physicalReturned, physicalOutput] + for (let left = 0; left < roots.length; left += 1) { + for (let right = left + 1; right < roots.length; right += 1) { + if (containsPath(roots[left], roots[right]) || containsPath(roots[right], roots[left])) { + throw new Error('Runtime native signing apply roots must be physically disjoint') + } + } + } + await assertAbsent(physicalOutput) + return { physicalSource, physicalReturned, physicalOutput } +} + +function localPath(root, portablePath) { + return resolve(root, ...portablePath.split('/')) +} + +function assertIdentityBoundSelection(identity, selection) { + if ( + !selection || + typeof selection !== 'object' || + !Array.isArray(selection.signingFiles) || + !Array.isArray(selection.preservedUpstreamFiles) + ) { + throw new Error('Runtime native signing apply requires a complete selection') + } + const assessments = + identity.os === 'win32' + ? [ + ...selection.signingFiles.map((entry) => ({ + path: entry.path, + sourceSha256: entry.sourceSha256, + status: 'unsigned' + })), + ...selection.preservedUpstreamFiles.map((entry) => ({ + path: entry.path, + sourceSha256: entry.sourceSha256, + status: 'valid-upstream', + signerSubject: entry.signerSubject, + signerThumbprint: entry.signerThumbprint + })) + ] + : [] + const expected = buildSshRelayRuntimeNativeSigningSelection(identity, assessments) + if (!isDeepStrictEqual(selection, expected)) { + // Why: signing selections cross a job boundary and must remain bound to the authenticated tree. + throw new Error('Runtime native signing apply selection and identity disagree') + } +} + +function finalIdentity(identity, returnedFiles) { + const returned = new Map(returnedFiles.map((entry) => [entry.path, entry])) + const entries = identity.entries.map((entry) => { + const signed = returned.get(entry.path) + return signed + ? { ...entry, size: signed.signedSize, sha256: signed.signedSha256 } + : { ...entry } + }) + const files = entries.filter((entry) => entry.type === 'file') + const { + archive: _archive, + contentId: _contentId, + expandedSize: _size, + fileCount: _count, + ...base + } = identity + const candidate = { + ...base, + entries, + fileCount: files.length, + expandedSize: files.reduce((total, entry) => total + entry.size, 0) + } + return { ...candidate, contentId: computeSshRelayRuntimeContentId(candidate) } +} + +async function copyRuntimeTree({ + identity, + returnedFiles, + physicalSource, + physicalReturned, + physicalOutput, + copyFileImpl +}) { + const returnedPaths = new Set(returnedFiles.map((entry) => entry.path)) + const directories = identity.entries + .filter((entry) => entry.type === 'directory') + .sort((left, right) => left.path.split('/').length - right.path.split('/').length) + for (const entry of directories) { + const destination = localPath(physicalOutput, entry.path) + await mkdir(destination, { mode: entry.mode }) + if (process.platform !== 'win32') { + await chmod(destination, entry.mode) + } + } + for (const entry of identity.entries.filter((candidate) => candidate.type === 'file')) { + const sourceRoot = returnedPaths.has(entry.path) ? physicalReturned : physicalSource + const destination = localPath(physicalOutput, entry.path) + await copyFileImpl(localPath(sourceRoot, entry.path), destination, constants.COPYFILE_EXCL) + if (process.platform !== 'win32') { + await chmod(destination, entry.mode) + } + } +} + +export async function applySshRelayRuntimeNativeSigningReturn({ + sourceRuntimeRoot, + returnedRoot, + outputRuntimeRoot, + identity, + selection, + copyFileImpl = copyFile +}) { + assertSshRelayRuntimeClosureEntries(identity) + assertIdentityBoundSelection(identity, selection) + if (selection.signingFiles.length === 0) { + throw new Error('Runtime native signing apply selection has no signing files') + } + const roots = await assertSshRelayRuntimeNativeSigningApplyRoots({ + sourceRuntimeRoot, + returnedRoot, + outputRuntimeRoot + }) + await verifyRuntimeTree(roots.physicalSource, identity) + const returned = await verifySshRelayRuntimeNativeSigningReturn({ + returnedRoot: roots.physicalReturned, + selection + }) + const candidateIdentity = finalIdentity(identity, returned.returnedFiles) + if (candidateIdentity.contentId === identity.contentId) { + throw new Error('Runtime native signing apply did not change content identity') + } + assertSshRelayRuntimeClosureEntries(candidateIdentity) + + let outputCreated = false + try { + await mkdir(roots.physicalOutput) + outputCreated = true + await copyRuntimeTree({ + identity, + returnedFiles: returned.returnedFiles, + ...roots, + copyFileImpl + }) + await verifyRuntimeTree(roots.physicalOutput, candidateIdentity) + return { + tupleId: identity.tupleId, + sourceContentId: identity.contentId, + finalContentId: candidateIdentity.contentId, + returnedFiles: returned.returnedFiles, + identity: candidateIdentity + } + } catch (error) { + if (outputCreated) { + // Why: an incomplete post-sign tree must never survive as a launchable or archivable candidate. + await rm(roots.physicalOutput, { recursive: true, force: true }) + } + throw error + } +} diff --git a/config/scripts/ssh-relay-runtime-native-signing-apply.test.mjs b/config/scripts/ssh-relay-runtime-native-signing-apply.test.mjs new file mode 100644 index 00000000000..34e9700241a --- /dev/null +++ b/config/scripts/ssh-relay-runtime-native-signing-apply.test.mjs @@ -0,0 +1,322 @@ +import { createHash } from 'node:crypto' +import { + appendFile, + copyFile, + cp, + lstat, + mkdir, + mkdtemp, + readFile, + rm, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { expectedSshRelayRuntimeClosureEntries } from './ssh-relay-runtime-closure.mjs' +import { computeSshRelayRuntimeContentId } from './ssh-relay-runtime-identity.mjs' +import { + applySshRelayRuntimeNativeSigningReturn, + assertSshRelayRuntimeNativeSigningApplyRoots +} from './ssh-relay-runtime-native-signing-apply.mjs' +import { buildSshRelayRuntimeNativeSigningPlan } from './ssh-relay-runtime-native-signing-plan.mjs' +import { buildSshRelayRuntimeNativeSigningSelection } from './ssh-relay-runtime-native-signing-selection.mjs' +import { sshRelayRuntimeCompatibility } from './ssh-relay-runtime-compatibility.mjs' + +function digest(bytes) { + return `sha256:${createHash('sha256').update(bytes).digest('hex')}` +} + +function platformForTuple(tupleId) { + return tupleId.startsWith('linux-') ? 'linux' : tupleId.startsWith('darwin-') ? 'darwin' : 'win32' +} + +async function runtimeFixture(tupleId) { + const root = await mkdtemp(join(tmpdir(), 'ssh-relay-native-signing-apply-')) + const runtimeRoot = join(root, 'runtime') + await mkdir(runtimeRoot) + const entries = [] + for (const entry of expectedSshRelayRuntimeClosureEntries(tupleId)) { + if (entry.type === 'directory') { + await mkdir(join(runtimeRoot, ...entry.path.split('/')), { + recursive: true, + mode: entry.mode + }) + entries.push(entry) + continue + } + const bytes = Buffer.from(`fixture:${tupleId}:${entry.path}`) + const filePath = join(runtimeRoot, ...entry.path.split('/')) + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, bytes, { mode: entry.mode }) + entries.push({ ...entry, size: bytes.length, sha256: digest(bytes) }) + } + const os = platformForTuple(tupleId) + const base = { + identitySchemaVersion: 1, + tupleId, + os, + architecture: tupleId.includes('arm64') ? 'arm64' : 'x64', + compatibility: sshRelayRuntimeCompatibility[tupleId], + nodeVersion: '24.18.0', + dependencies: { nodePtyVersion: '1.1.0', parcelWatcherVersion: '2.5.6' }, + entries + } + const files = entries.filter((entry) => entry.type === 'file') + return { + root, + runtimeRoot, + identity: { + ...base, + archive: { + fileName: `orca-relay-runtime-${tupleId}.fixture`, + size: 1, + sha256: digest('stale-unsigned-archive') + }, + contentId: computeSshRelayRuntimeContentId(base), + fileCount: files.length, + expandedSize: files.reduce((total, entry) => total + entry.size, 0) + } + } +} + +function selectionFor(identity) { + if (identity.os !== 'win32') { + return buildSshRelayRuntimeNativeSigningSelection(identity, []) + } + const preserved = new Set([ + 'node_modules/node-pty/build/Release/conpty/OpenConsole.exe', + 'node_modules/node-pty/build/Release/conpty/conpty.dll' + ]) + const assessments = buildSshRelayRuntimeNativeSigningPlan(identity).signingCandidates.map( + (entry) => + preserved.has(entry.path) + ? { + path: entry.path, + sourceSha256: entry.sourceSha256, + status: 'valid-upstream', + signerSubject: 'CN=Microsoft Corporation', + signerThumbprint: 'D'.repeat(40) + } + : { path: entry.path, sourceSha256: entry.sourceSha256, status: 'unsigned' } + ) + return buildSshRelayRuntimeNativeSigningSelection(identity, assessments) +} + +async function returnedTree(fixture, selection) { + const root = join(fixture.root, 'returned') + for (const entry of selection.signingFiles) { + const source = join(fixture.runtimeRoot, ...entry.path.split('/')) + const destination = join(root, ...entry.path.split('/')) + await mkdir(dirname(destination), { recursive: true }) + await cp(source, destination) + await appendFile(destination, ':signed') + } + return root +} + +describe('SSH relay runtime native signing return application', () => { + it('applies only exact returned files into a new full runtime and derives final identity', async () => { + for (const tupleId of ['darwin-arm64', 'win32-x64']) { + const fixture = await runtimeFixture(tupleId) + try { + const selection = selectionFor(fixture.identity) + const returnedRoot = await returnedTree(fixture, selection) + const outputRuntimeRoot = join(fixture.root, 'final-runtime') + const originalIdentity = structuredClone(fixture.identity) + const nodePath = fixture.identity.os === 'win32' ? 'bin/node.exe' : 'bin/node' + const sourceNode = await readFile(join(fixture.runtimeRoot, ...nodePath.split('/'))) + const sourceSigningBytes = new Map() + for (const entry of selection.signingFiles) { + sourceSigningBytes.set( + entry.path, + await readFile(join(fixture.runtimeRoot, ...entry.path.split('/'))) + ) + } + + const result = await applySshRelayRuntimeNativeSigningReturn({ + sourceRuntimeRoot: fixture.runtimeRoot, + returnedRoot, + outputRuntimeRoot, + identity: fixture.identity, + selection + }) + + expect(fixture.identity).toEqual(originalIdentity) + expect(result.identity.contentId).not.toBe(fixture.identity.contentId) + expect(result.identity).not.toHaveProperty('archive') + expect(result.identity.fileCount).toBe(fixture.identity.fileCount) + expect(result.returnedFiles).toHaveLength(selection.signingFiles.length) + expect(await readFile(join(outputRuntimeRoot, ...nodePath.split('/')))).toEqual(sourceNode) + expect(result.identity.entries.find((entry) => entry.path === nodePath)).toEqual( + fixture.identity.entries.find((entry) => entry.path === nodePath) + ) + for (const returned of result.returnedFiles) { + const finalEntry = result.identity.entries.find((entry) => entry.path === returned.path) + expect(finalEntry).toEqual( + expect.objectContaining({ size: returned.signedSize, sha256: returned.signedSha256 }) + ) + expect(await readFile(join(outputRuntimeRoot, ...returned.path.split('/')))).toEqual( + await readFile(join(returnedRoot, ...returned.path.split('/'))) + ) + expect(await readFile(join(fixture.runtimeRoot, ...returned.path.split('/')))).toEqual( + sourceSigningBytes.get(returned.path) + ) + } + for (const preserved of selection.preservedUpstreamFiles) { + expect(await readFile(join(outputRuntimeRoot, ...preserved.path.split('/')))).toEqual( + await readFile(join(fixture.runtimeRoot, ...preserved.path.split('/'))) + ) + expect(result.identity.entries.find((entry) => entry.path === preserved.path)).toEqual( + fixture.identity.entries.find((entry) => entry.path === preserved.path) + ) + } + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + } + }) + + it('rejects existing, source-nested, returned-nested, or overlapping roots', async () => { + const fixture = await runtimeFixture('darwin-x64') + try { + const selection = selectionFor(fixture.identity) + const returnedRoot = await returnedTree(fixture, selection) + const existing = join(fixture.root, 'existing') + await mkdir(existing) + await expect( + assertSshRelayRuntimeNativeSigningApplyRoots({ + sourceRuntimeRoot: fixture.runtimeRoot, + returnedRoot, + outputRuntimeRoot: existing + }) + ).rejects.toThrow(/exclusive/i) + await expect( + assertSshRelayRuntimeNativeSigningApplyRoots({ + sourceRuntimeRoot: fixture.runtimeRoot, + returnedRoot, + outputRuntimeRoot: join(fixture.runtimeRoot, 'nested') + }) + ).rejects.toThrow(/disjoint/i) + await expect( + assertSshRelayRuntimeNativeSigningApplyRoots({ + sourceRuntimeRoot: fixture.runtimeRoot, + returnedRoot, + outputRuntimeRoot: join(returnedRoot, 'nested') + }) + ).rejects.toThrow(/disjoint/i) + await expect( + assertSshRelayRuntimeNativeSigningApplyRoots({ + sourceRuntimeRoot: fixture.runtimeRoot, + returnedRoot: fixture.runtimeRoot, + outputRuntimeRoot: join(fixture.root, 'output') + }) + ).rejects.toThrow(/disjoint/i) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + }) + + it('removes the complete output if returned bytes change after initial verification', async () => { + const fixture = await runtimeFixture('darwin-arm64') + try { + const selection = selectionFor(fixture.identity) + const returnedRoot = await returnedTree(fixture, selection) + const outputRuntimeRoot = join(fixture.root, 'final-runtime') + const first = selection.signingFiles[0] + const firstReturnedSuffix = join(...first.path.split('/')) + let raced = false + await expect( + applySshRelayRuntimeNativeSigningReturn({ + sourceRuntimeRoot: fixture.runtimeRoot, + returnedRoot, + outputRuntimeRoot, + identity: fixture.identity, + selection, + copyFileImpl: async (source, destination, mode) => { + if (!raced && source.endsWith(firstReturnedSuffix)) { + raced = true + await appendFile(source, ':raced') + } + await copyFile(source, destination, mode) + } + }) + ).rejects.toThrow(/integrity mismatch/i) + await expect(lstat(outputRuntimeRoot)).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + }) + + it('rejects Linux and a mismatched selection before creating output', async () => { + const fixture = await runtimeFixture('linux-x64-glibc') + try { + const outputRuntimeRoot = join(fixture.root, 'final-runtime') + await expect( + applySshRelayRuntimeNativeSigningReturn({ + sourceRuntimeRoot: fixture.runtimeRoot, + returnedRoot: join(fixture.root, 'missing-return'), + outputRuntimeRoot, + identity: fixture.identity, + selection: selectionFor(fixture.identity) + }) + ).rejects.toThrow(/no signing files/i) + await expect(lstat(outputRuntimeRoot)).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + }) + + it('rejects a selection whose authenticated source bytes disagree with the identity', async () => { + const fixture = await runtimeFixture('darwin-x64') + try { + const selection = selectionFor(fixture.identity) + selection.signingFiles[0].sourceSha256 = `sha256:${'f'.repeat(64)}` + const outputRuntimeRoot = join(fixture.root, 'final-runtime') + await expect( + applySshRelayRuntimeNativeSigningReturn({ + sourceRuntimeRoot: fixture.runtimeRoot, + returnedRoot: join(fixture.root, 'missing-return'), + outputRuntimeRoot, + identity: fixture.identity, + selection + }) + ).rejects.toThrow(/selection and identity disagree/i) + await expect(lstat(outputRuntimeRoot)).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + }) + + it('removes the output if unsigned source bytes change during the copy', async () => { + const fixture = await runtimeFixture('win32-x64') + try { + const selection = selectionFor(fixture.identity) + const returnedRoot = await returnedTree(fixture, selection) + const outputRuntimeRoot = join(fixture.root, 'final-runtime') + const nodeSuffix = join('bin', 'node.exe') + let raced = false + await expect( + applySshRelayRuntimeNativeSigningReturn({ + sourceRuntimeRoot: fixture.runtimeRoot, + returnedRoot, + outputRuntimeRoot, + identity: fixture.identity, + selection, + copyFileImpl: async (source, destination, mode) => { + if (!raced && source.endsWith(nodeSuffix)) { + raced = true + await appendFile(source, ':raced') + } + await copyFile(source, destination, mode) + } + }) + ).rejects.toThrow(/integrity mismatch/i) + await expect(lstat(outputRuntimeRoot)).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + }) +}) diff --git a/config/scripts/ssh-relay-runtime-workflow.test.mjs b/config/scripts/ssh-relay-runtime-workflow.test.mjs index d90ce593555..2a93d082323 100644 --- a/config/scripts/ssh-relay-runtime-workflow.test.mjs +++ b/config/scripts/ssh-relay-runtime-workflow.test.mjs @@ -88,6 +88,7 @@ describe('SSH relay runtime artifact workflow', () => { source.match(/ssh-relay-runtime-windows-authenticode-assessment\.test\.mjs/g) ).toHaveLength(4) expect(source.match(/ssh-relay-runtime-native-signing-stage\.test\.mjs/g)).toHaveLength(4) + expect(source.match(/ssh-relay-runtime-native-signing-apply\.test\.mjs/g)).toHaveLength(4) expect( source.match(/node --check config\/scripts\/ssh-relay-runtime-native-signing-plan\.mjs/g) ).toHaveLength(2) @@ -105,6 +106,9 @@ describe('SSH relay runtime artifact workflow', () => { expect( source.match(/node --check config\/scripts\/ssh-relay-runtime-native-signing-stage\.mjs/g) ).toHaveLength(2) + expect( + source.match(/node --check config\/scripts\/ssh-relay-runtime-native-signing-apply\.mjs/g) + ).toHaveLength(2) expect( source.match(/node --check config\/scripts\/ssh-relay-runtime-closure\.mjs/g) ).toHaveLength(2)