mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
build(ssh): validate native signing payloads
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -127,6 +127,10 @@ jobs:
|
||||
node --check config/scripts/ssh-relay-runtime-toolchain.test.mjs
|
||||
node --check config/scripts/ssh-relay-runtime-native-signing-plan.mjs
|
||||
node --check config/scripts/ssh-relay-runtime-native-signing-plan.test.mjs
|
||||
node --check config/scripts/ssh-relay-runtime-native-signing-selection.mjs
|
||||
node --check config/scripts/ssh-relay-runtime-native-signing-selection.test.mjs
|
||||
node --check config/scripts/ssh-relay-runtime-native-signing-payload.mjs
|
||||
node --check config/scripts/ssh-relay-runtime-native-signing-payload.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 \
|
||||
@@ -140,6 +144,8 @@ jobs:
|
||||
config/scripts/ssh-relay-runtime-build.test.mjs \
|
||||
config/scripts/ssh-relay-runtime-linux-build-evidence.test.mjs \
|
||||
config/scripts/ssh-relay-runtime-native-signing-plan.test.mjs \
|
||||
config/scripts/ssh-relay-runtime-native-signing-selection.test.mjs \
|
||||
config/scripts/ssh-relay-runtime-native-signing-payload.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 \
|
||||
@@ -386,6 +392,14 @@ jobs:
|
||||
if ($LASTEXITCODE -ne 0) { throw 'runtime native signing plan script syntax check failed' }
|
||||
node --check config/scripts/ssh-relay-runtime-native-signing-plan.test.mjs
|
||||
if ($LASTEXITCODE -ne 0) { throw 'runtime native signing plan test syntax check failed' }
|
||||
node --check config/scripts/ssh-relay-runtime-native-signing-selection.mjs
|
||||
if ($LASTEXITCODE -ne 0) { throw 'runtime native signing selection script syntax check failed' }
|
||||
node --check config/scripts/ssh-relay-runtime-native-signing-selection.test.mjs
|
||||
if ($LASTEXITCODE -ne 0) { throw 'runtime native signing selection test syntax check failed' }
|
||||
node --check config/scripts/ssh-relay-runtime-native-signing-payload.mjs
|
||||
if ($LASTEXITCODE -ne 0) { throw 'runtime native signing payload script syntax check failed' }
|
||||
node --check config/scripts/ssh-relay-runtime-native-signing-payload.test.mjs
|
||||
if ($LASTEXITCODE -ne 0) { throw 'runtime native signing payload 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 `
|
||||
@@ -399,6 +413,8 @@ jobs:
|
||||
config/scripts/ssh-relay-runtime-build.test.mjs `
|
||||
config/scripts/ssh-relay-runtime-linux-build-evidence.test.mjs `
|
||||
config/scripts/ssh-relay-runtime-native-signing-plan.test.mjs `
|
||||
config/scripts/ssh-relay-runtime-native-signing-selection.test.mjs `
|
||||
config/scripts/ssh-relay-runtime-native-signing-payload.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 `
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { constants, createReadStream } from 'node:fs'
|
||||
import { copyFile, lstat, mkdir, readdir, realpath, rm } from 'node:fs/promises'
|
||||
import { basename, dirname, isAbsolute, relative, resolve } from 'node:path'
|
||||
|
||||
const MAX_SIGNED_FILE_GROWTH_BYTES = 4 * 1024 * 1024
|
||||
const MAX_RETURNED_PAYLOAD_BYTES = 64 * 1024 * 1024
|
||||
|
||||
function localPath(root, portablePath) {
|
||||
if (
|
||||
typeof portablePath !== 'string' ||
|
||||
portablePath.length === 0 ||
|
||||
portablePath.includes('\\') ||
|
||||
portablePath.startsWith('/') ||
|
||||
portablePath.split('/').some((segment) => !segment || segment === '.' || segment === '..')
|
||||
) {
|
||||
throw new Error(`Runtime native signing rejects non-portable path: ${portablePath}`)
|
||||
}
|
||||
return resolve(root, ...portablePath.split('/'))
|
||||
}
|
||||
|
||||
async function sha256File(path) {
|
||||
const hash = createHash('sha256')
|
||||
for await (const chunk of createReadStream(path)) {
|
||||
hash.update(chunk)
|
||||
}
|
||||
return `sha256:${hash.digest('hex')}`
|
||||
}
|
||||
|
||||
async function assertRegularFile(path, label) {
|
||||
const metadata = await lstat(path)
|
||||
if (metadata.isSymbolicLink()) {
|
||||
throw new Error(`Runtime native signing ${label} is a symbolic link: ${path}`)
|
||||
}
|
||||
if (!metadata.isFile()) {
|
||||
throw new Error(`Runtime native signing ${label} is not a regular file: ${path}`)
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
async function verifySourceFile(runtimeRoot, entry) {
|
||||
const path = localPath(runtimeRoot, entry.path)
|
||||
const metadata = await assertRegularFile(path, 'source')
|
||||
if (metadata.size !== entry.sourceSize) {
|
||||
throw new Error(`Runtime native signing source has wrong authenticated size: ${entry.path}`)
|
||||
}
|
||||
if ((await sha256File(path)) !== entry.sourceSha256) {
|
||||
throw new Error(`Runtime native signing source has wrong authenticated hash: ${entry.path}`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertExclusiveStageLocation(runtimeRoot, stagingRoot) {
|
||||
const relativeStage = relative(runtimeRoot, stagingRoot)
|
||||
if (relativeStage === '' || (!relativeStage.startsWith('..') && !isAbsolute(relativeStage))) {
|
||||
throw new Error('Runtime native signing staging root must be outside the runtime')
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSigningRoots(runtimeRoot, stagingRoot) {
|
||||
const runtimeMetadata = await lstat(runtimeRoot)
|
||||
if (runtimeMetadata.isSymbolicLink() || !runtimeMetadata.isDirectory()) {
|
||||
throw new Error('Runtime native signing source root must be a real directory')
|
||||
}
|
||||
const physicalRuntimeRoot = await realpath(runtimeRoot)
|
||||
const physicalStagingParent = await realpath(dirname(stagingRoot))
|
||||
const physicalStagingRoot = resolve(physicalStagingParent, basename(stagingRoot))
|
||||
assertExclusiveStageLocation(physicalRuntimeRoot, physicalStagingRoot)
|
||||
return { physicalRuntimeRoot, physicalStagingRoot }
|
||||
}
|
||||
|
||||
async function assertPathAbsent(path) {
|
||||
try {
|
||||
await lstat(path)
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
throw new Error('Runtime native signing requires an exclusive staging root')
|
||||
}
|
||||
|
||||
async function copySigningFile(runtimeRoot, stagingRoot, entry) {
|
||||
const source = localPath(runtimeRoot, entry.path)
|
||||
const destination = localPath(stagingRoot, entry.path)
|
||||
await mkdir(dirname(destination), { recursive: true })
|
||||
await copyFile(source, destination, constants.COPYFILE_EXCL)
|
||||
const metadata = await assertRegularFile(destination, 'staged file')
|
||||
if (
|
||||
metadata.size !== entry.sourceSize ||
|
||||
(await sha256File(destination)) !== entry.sourceSha256
|
||||
) {
|
||||
throw new Error(`Runtime native signing staged bytes changed during copy: ${entry.path}`)
|
||||
}
|
||||
return { path: entry.path, sourceSha256: entry.sourceSha256, sourceSize: entry.sourceSize }
|
||||
}
|
||||
|
||||
export async function stageSshRelayRuntimeNativeSigningPayload({
|
||||
runtimeRoot,
|
||||
stagingRoot,
|
||||
selection
|
||||
}) {
|
||||
const absoluteRuntimeRoot = resolve(runtimeRoot)
|
||||
const absoluteStagingRoot = resolve(stagingRoot)
|
||||
const { physicalRuntimeRoot, physicalStagingRoot } = await resolveSigningRoots(
|
||||
absoluteRuntimeRoot,
|
||||
absoluteStagingRoot
|
||||
)
|
||||
await assertPathAbsent(physicalStagingRoot)
|
||||
await Promise.all(
|
||||
selection.verificationFiles.map((entry) => verifySourceFile(physicalRuntimeRoot, entry))
|
||||
)
|
||||
if (selection.signingFiles.length === 0) {
|
||||
return { tupleId: selection.tupleId, stagingRequired: false, stagedFiles: [], stagedSize: 0 }
|
||||
}
|
||||
|
||||
await mkdir(physicalStagingRoot)
|
||||
try {
|
||||
const stagedFiles = []
|
||||
for (const entry of selection.signingFiles) {
|
||||
stagedFiles.push(await copySigningFile(physicalRuntimeRoot, physicalStagingRoot, entry))
|
||||
}
|
||||
return {
|
||||
tupleId: selection.tupleId,
|
||||
stagingRequired: true,
|
||||
stagedFiles,
|
||||
stagedSize: stagedFiles.reduce((total, entry) => total + entry.sourceSize, 0)
|
||||
}
|
||||
} catch (error) {
|
||||
// Why: a partial signing payload must never be mistaken for a complete retryable input.
|
||||
await rm(physicalStagingRoot, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function expectedDirectories(files) {
|
||||
const directories = new Set()
|
||||
for (const file of files) {
|
||||
const segments = file.path.split('/')
|
||||
for (let depth = 1; depth < segments.length; depth += 1) {
|
||||
directories.add(segments.slice(0, depth).join('/'))
|
||||
}
|
||||
}
|
||||
return directories
|
||||
}
|
||||
|
||||
async function walkReturnedTree(
|
||||
root,
|
||||
segments = [],
|
||||
result = { directories: new Set(), files: new Map() }
|
||||
) {
|
||||
for (const directoryEntry of await readdir(root, { withFileTypes: true })) {
|
||||
const childSegments = [...segments, directoryEntry.name]
|
||||
const portablePath = childSegments.join('/')
|
||||
const path = resolve(root, directoryEntry.name)
|
||||
const metadata = await lstat(path)
|
||||
if (metadata.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Runtime native signing returned tree contains a symbolic link: ${portablePath}`
|
||||
)
|
||||
}
|
||||
if (metadata.isDirectory()) {
|
||||
result.directories.add(portablePath)
|
||||
await walkReturnedTree(path, childSegments, result)
|
||||
} else if (metadata.isFile()) {
|
||||
result.files.set(portablePath, { path, metadata })
|
||||
} else {
|
||||
throw new Error(
|
||||
`Runtime native signing returned tree contains a special entry: ${portablePath}`
|
||||
)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function assertExactReturnedClosure(actual, expectedFiles) {
|
||||
const expectedFilePaths = new Set(expectedFiles.map((entry) => entry.path))
|
||||
for (const entry of expectedFiles) {
|
||||
if (!actual.files.has(entry.path)) {
|
||||
throw new Error(`Runtime native signing is missing returned file: ${entry.path}`)
|
||||
}
|
||||
}
|
||||
for (const path of actual.files.keys()) {
|
||||
if (!expectedFilePaths.has(path)) {
|
||||
throw new Error(`Runtime native signing has unexpected returned file: ${path}`)
|
||||
}
|
||||
}
|
||||
const directories = expectedDirectories(expectedFiles)
|
||||
for (const path of actual.directories) {
|
||||
if (!directories.has(path)) {
|
||||
throw new Error(`Runtime native signing has unexpected returned directory: ${path}`)
|
||||
}
|
||||
}
|
||||
for (const path of directories) {
|
||||
if (!actual.directories.has(path)) {
|
||||
throw new Error(`Runtime native signing is missing returned directory: ${path}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifySshRelayRuntimeNativeSigningReturn({ returnedRoot, selection }) {
|
||||
if (selection.signingFiles.length === 0) {
|
||||
throw new Error('Runtime native signing selection has no returned payload')
|
||||
}
|
||||
const absoluteReturnedRoot = resolve(returnedRoot)
|
||||
const rootMetadata = await lstat(absoluteReturnedRoot)
|
||||
if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {
|
||||
throw new Error('Runtime native signing returned root must be a real directory')
|
||||
}
|
||||
const actual = await walkReturnedTree(absoluteReturnedRoot)
|
||||
assertExactReturnedClosure(actual, selection.signingFiles)
|
||||
|
||||
let returnedSize = 0
|
||||
const returnedFiles = []
|
||||
for (const entry of selection.signingFiles) {
|
||||
const returned = actual.files.get(entry.path)
|
||||
if (
|
||||
returned.metadata.size <= 0 ||
|
||||
returned.metadata.size > entry.sourceSize + MAX_SIGNED_FILE_GROWTH_BYTES
|
||||
) {
|
||||
throw new Error(`Runtime native signing returned file exceeds size bound: ${entry.path}`)
|
||||
}
|
||||
returnedSize += returned.metadata.size
|
||||
if (returnedSize > MAX_RETURNED_PAYLOAD_BYTES) {
|
||||
throw new Error('Runtime native signing returned payload exceeds total size bound')
|
||||
}
|
||||
const signedSha256 = await sha256File(returned.path)
|
||||
if (signedSha256 === entry.sourceSha256) {
|
||||
throw new Error(`Runtime native signing returned file did not change: ${entry.path}`)
|
||||
}
|
||||
returnedFiles.push({
|
||||
path: entry.path,
|
||||
role: entry.role,
|
||||
sourceSha256: entry.sourceSha256,
|
||||
signedSha256,
|
||||
signedSize: returned.metadata.size
|
||||
})
|
||||
}
|
||||
return { tupleId: selection.tupleId, returnedFiles, returnedSize }
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
appendFile,
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
symlink,
|
||||
truncate,
|
||||
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 {
|
||||
stageSshRelayRuntimeNativeSigningPayload,
|
||||
verifySshRelayRuntimeNativeSigningReturn
|
||||
} from './ssh-relay-runtime-native-signing-payload.mjs'
|
||||
import { buildSshRelayRuntimeNativeSigningSelection } from './ssh-relay-runtime-native-signing-selection.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-payload-'))
|
||||
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 })
|
||||
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) })
|
||||
}
|
||||
return {
|
||||
root,
|
||||
runtimeRoot,
|
||||
identity: {
|
||||
tupleId,
|
||||
os: platformForTuple(tupleId),
|
||||
nodeVersion: '24.18.0',
|
||||
dependencies: { nodePtyVersion: '1.1.0', parcelWatcherVersion: '2.5.6' },
|
||||
entries
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function windowsAssessments(identity, preservedPath) {
|
||||
const nativeEntries = identity.entries.filter(
|
||||
(entry) =>
|
||||
entry.type === 'file' &&
|
||||
['node-pty-native', 'parcel-watcher-native', 'native-runtime'].includes(entry.role)
|
||||
)
|
||||
return nativeEntries.map((entry) =>
|
||||
entry.path === preservedPath
|
||||
? {
|
||||
path: entry.path,
|
||||
sourceSha256: entry.sha256,
|
||||
status: 'valid-upstream',
|
||||
signerSubject: 'CN=Microsoft Corporation',
|
||||
signerThumbprint: 'B'.repeat(40)
|
||||
}
|
||||
: { path: entry.path, sourceSha256: entry.sha256, status: 'unsigned' }
|
||||
)
|
||||
}
|
||||
|
||||
async function createReturnedTree(stagingRoot, returnedRoot, selection) {
|
||||
await cp(stagingRoot, returnedRoot, { recursive: true, errorOnExist: true, force: false })
|
||||
for (const entry of selection.signingFiles) {
|
||||
await appendFile(join(returnedRoot, ...entry.path.split('/')), ':signed')
|
||||
}
|
||||
}
|
||||
|
||||
describe('SSH relay runtime native signing payload', () => {
|
||||
it('authenticates Linux native bytes without creating a signing payload', async () => {
|
||||
const fixture = await runtimeFixture('linux-x64-glibc')
|
||||
try {
|
||||
const selection = buildSshRelayRuntimeNativeSigningSelection(fixture.identity, [])
|
||||
const stagingRoot = join(fixture.root, 'stage')
|
||||
await expect(
|
||||
stageSshRelayRuntimeNativeSigningPayload({
|
||||
runtimeRoot: fixture.runtimeRoot,
|
||||
stagingRoot,
|
||||
selection
|
||||
})
|
||||
).resolves.toEqual({
|
||||
tupleId: 'linux-x64-glibc',
|
||||
stagingRequired: false,
|
||||
stagedFiles: [],
|
||||
stagedSize: 0
|
||||
})
|
||||
await expect(lstat(stagingRoot)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
} finally {
|
||||
await rm(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies all native bytes before exclusively staging only macOS signing files', async () => {
|
||||
const fixture = await runtimeFixture('darwin-arm64')
|
||||
try {
|
||||
const selection = buildSshRelayRuntimeNativeSigningSelection(fixture.identity, [])
|
||||
const stagingRoot = join(fixture.root, 'stage')
|
||||
const report = await stageSshRelayRuntimeNativeSigningPayload({
|
||||
runtimeRoot: fixture.runtimeRoot,
|
||||
stagingRoot,
|
||||
selection
|
||||
})
|
||||
|
||||
expect(report.stagedFiles.map((entry) => entry.path)).toEqual(
|
||||
selection.signingFiles.map((entry) => entry.path)
|
||||
)
|
||||
await expect(lstat(join(stagingRoot, 'bin', 'node'))).rejects.toMatchObject({
|
||||
code: 'ENOENT'
|
||||
})
|
||||
for (const entry of selection.signingFiles) {
|
||||
await expect(readFile(join(stagingRoot, ...entry.path.split('/')))).resolves.toEqual(
|
||||
await readFile(join(fixture.runtimeRoot, ...entry.path.split('/')))
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await rm(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not stage a Windows file whose valid upstream signature is preserved', async () => {
|
||||
const fixture = await runtimeFixture('win32-x64')
|
||||
try {
|
||||
const preservedPath = 'node_modules/node-pty/build/Release/conpty/OpenConsole.exe'
|
||||
const selection = buildSshRelayRuntimeNativeSigningSelection(
|
||||
fixture.identity,
|
||||
windowsAssessments(fixture.identity, preservedPath)
|
||||
)
|
||||
const stagingRoot = join(fixture.root, 'stage')
|
||||
const report = await stageSshRelayRuntimeNativeSigningPayload({
|
||||
runtimeRoot: fixture.runtimeRoot,
|
||||
stagingRoot,
|
||||
selection
|
||||
})
|
||||
|
||||
expect(report.stagedFiles).toHaveLength(4)
|
||||
await expect(lstat(join(stagingRoot, ...preservedPath.split('/')))).rejects.toMatchObject({
|
||||
code: 'ENOENT'
|
||||
})
|
||||
} finally {
|
||||
await rm(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects source mutation before creating a stage', async () => {
|
||||
const fixture = await runtimeFixture('darwin-x64')
|
||||
try {
|
||||
const selection = buildSshRelayRuntimeNativeSigningSelection(fixture.identity, [])
|
||||
const stagingRoot = join(fixture.root, 'stage')
|
||||
await appendFile(join(fixture.runtimeRoot, 'bin', 'node'), ':mutated')
|
||||
await expect(
|
||||
stageSshRelayRuntimeNativeSigningPayload({
|
||||
runtimeRoot: fixture.runtimeRoot,
|
||||
stagingRoot,
|
||||
selection
|
||||
})
|
||||
).rejects.toThrow(/authenticated size/i)
|
||||
await expect(lstat(stagingRoot)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
} finally {
|
||||
await rm(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an existing or source-nested staging root', async () => {
|
||||
const fixture = await runtimeFixture('darwin-arm64')
|
||||
try {
|
||||
const selection = buildSshRelayRuntimeNativeSigningSelection(fixture.identity, [])
|
||||
const existing = join(fixture.root, 'existing')
|
||||
await mkdir(existing)
|
||||
await expect(
|
||||
stageSshRelayRuntimeNativeSigningPayload({
|
||||
runtimeRoot: fixture.runtimeRoot,
|
||||
stagingRoot: existing,
|
||||
selection
|
||||
})
|
||||
).rejects.toThrow(/exclusive/i)
|
||||
await expect(
|
||||
stageSshRelayRuntimeNativeSigningPayload({
|
||||
runtimeRoot: fixture.runtimeRoot,
|
||||
stagingRoot: join(fixture.runtimeRoot, 'nested-stage'),
|
||||
selection
|
||||
})
|
||||
).rejects.toThrow(/outside the runtime/i)
|
||||
} finally {
|
||||
await rm(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('removes a partial stage when authenticated signing-file copy fails', async () => {
|
||||
const fixture = await runtimeFixture('darwin-arm64')
|
||||
try {
|
||||
const selection = buildSshRelayRuntimeNativeSigningSelection(fixture.identity, [])
|
||||
selection.signingFiles[1].sourceSha256 = `sha256:${'f'.repeat(64)}`
|
||||
const stagingRoot = join(fixture.root, 'stage')
|
||||
await expect(
|
||||
stageSshRelayRuntimeNativeSigningPayload({
|
||||
runtimeRoot: fixture.runtimeRoot,
|
||||
stagingRoot,
|
||||
selection
|
||||
})
|
||||
).rejects.toThrow(/changed during copy/i)
|
||||
await expect(lstat(stagingRoot)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
} finally {
|
||||
await rm(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts an exact returned tree only after every staged hash changes', async () => {
|
||||
const fixture = await runtimeFixture('darwin-arm64')
|
||||
try {
|
||||
const selection = buildSshRelayRuntimeNativeSigningSelection(fixture.identity, [])
|
||||
const stagingRoot = join(fixture.root, 'stage')
|
||||
const returnedRoot = join(fixture.root, 'returned')
|
||||
await stageSshRelayRuntimeNativeSigningPayload({
|
||||
runtimeRoot: fixture.runtimeRoot,
|
||||
stagingRoot,
|
||||
selection
|
||||
})
|
||||
await createReturnedTree(stagingRoot, returnedRoot, selection)
|
||||
|
||||
const report = await verifySshRelayRuntimeNativeSigningReturn({ returnedRoot, selection })
|
||||
expect(report.returnedFiles).toHaveLength(3)
|
||||
expect(report.returnedFiles.every((entry) => entry.signedSha256 !== entry.sourceSha256)).toBe(
|
||||
true
|
||||
)
|
||||
} finally {
|
||||
await rm(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unchanged, missing, extra, and oversized returned bytes', async () => {
|
||||
const fixture = await runtimeFixture('darwin-x64')
|
||||
try {
|
||||
const selection = buildSshRelayRuntimeNativeSigningSelection(fixture.identity, [])
|
||||
const stagingRoot = join(fixture.root, 'stage')
|
||||
await stageSshRelayRuntimeNativeSigningPayload({
|
||||
runtimeRoot: fixture.runtimeRoot,
|
||||
stagingRoot,
|
||||
selection
|
||||
})
|
||||
|
||||
const unchangedRoot = join(fixture.root, 'unchanged')
|
||||
await cp(stagingRoot, unchangedRoot, { recursive: true })
|
||||
await expect(
|
||||
verifySshRelayRuntimeNativeSigningReturn({ returnedRoot: unchangedRoot, selection })
|
||||
).rejects.toThrow(/did not change/i)
|
||||
|
||||
const missingRoot = join(fixture.root, 'missing')
|
||||
await createReturnedTree(stagingRoot, missingRoot, selection)
|
||||
await rm(join(missingRoot, ...selection.signingFiles[0].path.split('/')))
|
||||
await expect(
|
||||
verifySshRelayRuntimeNativeSigningReturn({ returnedRoot: missingRoot, selection })
|
||||
).rejects.toThrow(/missing returned file/i)
|
||||
|
||||
const extraRoot = join(fixture.root, 'extra')
|
||||
await createReturnedTree(stagingRoot, extraRoot, selection)
|
||||
await writeFile(join(extraRoot, 'extra.node'), 'extra')
|
||||
await expect(
|
||||
verifySshRelayRuntimeNativeSigningReturn({ returnedRoot: extraRoot, selection })
|
||||
).rejects.toThrow(/unexpected returned file/i)
|
||||
|
||||
const oversizedRoot = join(fixture.root, 'oversized')
|
||||
await createReturnedTree(stagingRoot, oversizedRoot, selection)
|
||||
const first = selection.signingFiles[0]
|
||||
await truncate(
|
||||
join(oversizedRoot, ...first.path.split('/')),
|
||||
first.sourceSize + 4 * 1024 * 1024 + 1
|
||||
)
|
||||
await expect(
|
||||
verifySshRelayRuntimeNativeSigningReturn({ returnedRoot: oversizedRoot, selection })
|
||||
).rejects.toThrow(/size bound/i)
|
||||
} finally {
|
||||
await rm(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'rejects a symlink in returned signing bytes',
|
||||
async () => {
|
||||
const fixture = await runtimeFixture('darwin-arm64')
|
||||
try {
|
||||
const selection = buildSshRelayRuntimeNativeSigningSelection(fixture.identity, [])
|
||||
const returnedRoot = join(fixture.root, 'returned')
|
||||
const target = join(fixture.root, 'target')
|
||||
const first = selection.signingFiles[0]
|
||||
await writeFile(target, 'signed')
|
||||
await mkdir(dirname(join(returnedRoot, ...first.path.split('/'))), { recursive: true })
|
||||
await symlink(target, join(returnedRoot, ...first.path.split('/')))
|
||||
await expect(
|
||||
verifySshRelayRuntimeNativeSigningReturn({ returnedRoot, selection })
|
||||
).rejects.toThrow(/symbolic link/i)
|
||||
} finally {
|
||||
await rm(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')('rejects a symlinked native source file', async () => {
|
||||
const fixture = await runtimeFixture('darwin-x64')
|
||||
try {
|
||||
const selection = buildSshRelayRuntimeNativeSigningSelection(fixture.identity, [])
|
||||
const first = selection.signingFiles[0]
|
||||
const sourcePath = join(fixture.runtimeRoot, ...first.path.split('/'))
|
||||
const target = join(fixture.root, 'native-target')
|
||||
await cp(sourcePath, target)
|
||||
await rm(sourcePath)
|
||||
await symlink(target, sourcePath)
|
||||
await expect(
|
||||
stageSshRelayRuntimeNativeSigningPayload({
|
||||
runtimeRoot: fixture.runtimeRoot,
|
||||
stagingRoot: join(fixture.root, 'stage'),
|
||||
selection
|
||||
})
|
||||
).rejects.toThrow(/source is a symbolic link/i)
|
||||
} finally {
|
||||
await rm(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'rejects a symlinked staging parent that resolves inside the runtime',
|
||||
async () => {
|
||||
const fixture = await runtimeFixture('darwin-arm64')
|
||||
try {
|
||||
const selection = buildSshRelayRuntimeNativeSigningSelection(fixture.identity, [])
|
||||
const redirectedParent = join(fixture.root, 'redirected-parent')
|
||||
await symlink(fixture.runtimeRoot, redirectedParent)
|
||||
await expect(
|
||||
stageSshRelayRuntimeNativeSigningPayload({
|
||||
runtimeRoot: fixture.runtimeRoot,
|
||||
stagingRoot: join(redirectedParent, 'stage'),
|
||||
selection
|
||||
})
|
||||
).rejects.toThrow(/outside the runtime/i)
|
||||
} finally {
|
||||
await rm(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import { buildSshRelayRuntimeNativeSigningPlan } from './ssh-relay-runtime-native-signing-plan.mjs'
|
||||
|
||||
const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/
|
||||
const WINDOWS_UNSIGNED_FIELDS = ['path', 'sourceSha256', 'status']
|
||||
const WINDOWS_VALID_FIELDS = ['path', 'signerSubject', 'signerThumbprint', 'sourceSha256', 'status']
|
||||
|
||||
function assertExactFields(value, expected, label) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`Runtime native signing ${label} must be an object`)
|
||||
}
|
||||
const actual = Object.keys(value).sort()
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((field, index) => field !== expected[index])
|
||||
) {
|
||||
throw new Error(`Runtime native signing ${label} has unexpected fields`)
|
||||
}
|
||||
}
|
||||
|
||||
function authenticatedFile(identityEntries, planEntry) {
|
||||
const entry = identityEntries.get(planEntry.path)
|
||||
if (!entry || entry.type !== 'file') {
|
||||
throw new Error(`Runtime native signing identity is missing file: ${planEntry.path}`)
|
||||
}
|
||||
if (!Number.isSafeInteger(entry.size) || entry.size <= 0) {
|
||||
throw new Error(`Runtime native signing identity has invalid size: ${planEntry.path}`)
|
||||
}
|
||||
if (!SHA256_PATTERN.test(entry.sha256)) {
|
||||
throw new Error(`Runtime native signing identity has invalid digest: ${planEntry.path}`)
|
||||
}
|
||||
if (entry.sha256 !== planEntry.sourceSha256 || entry.role !== planEntry.role) {
|
||||
throw new Error(`Runtime native signing identity disagrees with plan: ${planEntry.path}`)
|
||||
}
|
||||
return {
|
||||
path: entry.path,
|
||||
role: entry.role,
|
||||
sourceSha256: entry.sha256,
|
||||
sourceSize: entry.size
|
||||
}
|
||||
}
|
||||
|
||||
function indexIdentityFiles(identity) {
|
||||
const entries = new Map()
|
||||
for (const entry of identity.entries) {
|
||||
if (entry.type === 'file') {
|
||||
entries.set(entry.path, entry)
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function containsAsciiControl(value) {
|
||||
return [...value].some((character) => {
|
||||
const codePoint = character.codePointAt(0)
|
||||
return codePoint <= 0x1f || codePoint === 0x7f
|
||||
})
|
||||
}
|
||||
|
||||
function assertSignerIdentity(assessment) {
|
||||
if (
|
||||
typeof assessment.signerSubject !== 'string' ||
|
||||
assessment.signerSubject.length === 0 ||
|
||||
assessment.signerSubject.length > 1024 ||
|
||||
containsAsciiControl(assessment.signerSubject)
|
||||
) {
|
||||
throw new Error(
|
||||
`Runtime native signing assessment has invalid signer subject: ${assessment.path}`
|
||||
)
|
||||
}
|
||||
if (!/^[0-9a-f]{40}$/i.test(assessment.signerThumbprint)) {
|
||||
throw new Error(
|
||||
`Runtime native signing assessment has invalid signer thumbprint: ${assessment.path}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function windowsSelection(plan, identityEntries, assessments) {
|
||||
if (!Array.isArray(assessments)) {
|
||||
throw new Error('Runtime native signing Windows assessments must be an array')
|
||||
}
|
||||
const candidates = new Map(plan.signingCandidates.map((entry) => [entry.path, entry]))
|
||||
const indexed = new Map()
|
||||
for (const assessment of assessments) {
|
||||
if (!assessment || typeof assessment.path !== 'string' || !candidates.has(assessment.path)) {
|
||||
throw new Error(`Runtime native signing has unexpected assessment: ${assessment?.path}`)
|
||||
}
|
||||
if (indexed.has(assessment.path)) {
|
||||
throw new Error(`Runtime native signing has duplicate assessment: ${assessment.path}`)
|
||||
}
|
||||
if (!['unsigned', 'valid-upstream'].includes(assessment.status)) {
|
||||
throw new Error(`Runtime native signing rejects signature status: ${assessment.status}`)
|
||||
}
|
||||
assertExactFields(
|
||||
assessment,
|
||||
assessment.status === 'unsigned' ? WINDOWS_UNSIGNED_FIELDS : WINDOWS_VALID_FIELDS,
|
||||
'assessment'
|
||||
)
|
||||
const candidate = candidates.get(assessment.path)
|
||||
if (assessment.sourceSha256 !== candidate.sourceSha256) {
|
||||
throw new Error(`Runtime native signing assessment has wrong source hash: ${assessment.path}`)
|
||||
}
|
||||
if (assessment.status === 'valid-upstream') {
|
||||
assertSignerIdentity(assessment)
|
||||
}
|
||||
indexed.set(assessment.path, assessment)
|
||||
}
|
||||
|
||||
return plan.signingCandidates.map((candidate) => {
|
||||
const assessment = indexed.get(candidate.path)
|
||||
if (!assessment) {
|
||||
throw new Error(`Runtime native signing is missing assessment: ${candidate.path}`)
|
||||
}
|
||||
const file = authenticatedFile(identityEntries, candidate)
|
||||
return assessment.status === 'unsigned'
|
||||
? { ...file, action: 'signpath-required' }
|
||||
: {
|
||||
...file,
|
||||
action: 'preserve-valid-upstream',
|
||||
signerSubject: assessment.signerSubject,
|
||||
signerThumbprint: assessment.signerThumbprint.toUpperCase()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function buildSshRelayRuntimeNativeSigningSelection(identity, assessments) {
|
||||
const plan = buildSshRelayRuntimeNativeSigningPlan(identity)
|
||||
const identityEntries = indexIdentityFiles(identity)
|
||||
const immutableVendorFiles = plan.immutableVendorFiles.map((entry) => ({
|
||||
...authenticatedFile(identityEntries, entry),
|
||||
action: entry.action
|
||||
}))
|
||||
const verificationFiles = plan.verificationFiles.map((entry) =>
|
||||
authenticatedFile(identityEntries, entry)
|
||||
)
|
||||
|
||||
let candidateFiles
|
||||
if (plan.platform === 'win32') {
|
||||
candidateFiles = windowsSelection(plan, identityEntries, assessments)
|
||||
} else {
|
||||
if (!Array.isArray(assessments) || assessments.length !== 0) {
|
||||
throw new Error(`Runtime native signing ${plan.platform} does not accept assessments`)
|
||||
}
|
||||
candidateFiles = plan.signingCandidates.map((entry) => ({
|
||||
...authenticatedFile(identityEntries, entry),
|
||||
action: entry.action
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
tupleId: plan.tupleId,
|
||||
platform: plan.platform,
|
||||
policy: plan.policy,
|
||||
immutableVendorFiles,
|
||||
signingFiles: candidateFiles.filter((entry) => entry.action !== 'preserve-valid-upstream'),
|
||||
preservedUpstreamFiles: candidateFiles.filter(
|
||||
(entry) => entry.action === 'preserve-valid-upstream'
|
||||
),
|
||||
verificationFiles
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { expectedSshRelayRuntimeClosureEntries } from './ssh-relay-runtime-closure.mjs'
|
||||
import { buildSshRelayRuntimeNativeSigningPlan } from './ssh-relay-runtime-native-signing-plan.mjs'
|
||||
import { buildSshRelayRuntimeNativeSigningSelection } from './ssh-relay-runtime-native-signing-selection.mjs'
|
||||
|
||||
const DIGEST = `sha256:${'a'.repeat(64)}`
|
||||
|
||||
function identityFor(tupleId) {
|
||||
const os = tupleId.startsWith('linux-')
|
||||
? 'linux'
|
||||
: tupleId.startsWith('darwin-')
|
||||
? 'darwin'
|
||||
: 'win32'
|
||||
return {
|
||||
tupleId,
|
||||
os,
|
||||
nodeVersion: '24.18.0',
|
||||
dependencies: { nodePtyVersion: '1.1.0', parcelWatcherVersion: '2.5.6' },
|
||||
entries: expectedSshRelayRuntimeClosureEntries(tupleId).map((entry) =>
|
||||
entry.type === 'file' ? { ...entry, size: 100, sha256: DIGEST } : entry
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function windowsAssessments(identity, preservedPath) {
|
||||
return buildSshRelayRuntimeNativeSigningPlan(identity).signingCandidates.map((entry) =>
|
||||
entry.path === preservedPath
|
||||
? {
|
||||
path: entry.path,
|
||||
sourceSha256: entry.sourceSha256,
|
||||
status: 'valid-upstream',
|
||||
signerSubject: 'CN=Microsoft Corporation',
|
||||
signerThumbprint: 'A'.repeat(40)
|
||||
}
|
||||
: { path: entry.path, sourceSha256: entry.sourceSha256, status: 'unsigned' }
|
||||
)
|
||||
}
|
||||
|
||||
describe('SSH relay runtime native signing selection', () => {
|
||||
it('keeps Linux hash-only and official Node verification exact', () => {
|
||||
const selection = buildSshRelayRuntimeNativeSigningSelection(identityFor('linux-x64-glibc'), [])
|
||||
|
||||
expect(selection.signingFiles).toEqual([])
|
||||
expect(selection.immutableVendorFiles).toEqual([
|
||||
expect.objectContaining({ path: 'bin/node', action: 'preserve-exact-bytes', sourceSize: 100 })
|
||||
])
|
||||
expect(selection.verificationFiles).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('requires every macOS candidate and ignores no assessment input', () => {
|
||||
const identity = identityFor('darwin-arm64')
|
||||
const selection = buildSshRelayRuntimeNativeSigningSelection(identity, [])
|
||||
|
||||
expect(selection.signingFiles.map((entry) => [entry.path, entry.action])).toEqual([
|
||||
['node_modules/@parcel/watcher-darwin-arm64/watcher.node', 'developer-id-required'],
|
||||
['node_modules/node-pty/build/Release/pty.node', 'developer-id-required'],
|
||||
['node_modules/node-pty/build/Release/spawn-helper', 'developer-id-required']
|
||||
])
|
||||
expect(() =>
|
||||
buildSshRelayRuntimeNativeSigningSelection(identity, [
|
||||
{ path: selection.signingFiles[0].path, status: 'unsigned', sourceSha256: DIGEST }
|
||||
])
|
||||
).toThrow(/does not accept assessments/i)
|
||||
})
|
||||
|
||||
it('stages only unsigned Windows candidates and preserves valid upstream identity', () => {
|
||||
const identity = identityFor('win32-x64')
|
||||
const preservedPath = 'node_modules/node-pty/build/Release/conpty/OpenConsole.exe'
|
||||
const selection = buildSshRelayRuntimeNativeSigningSelection(
|
||||
identity,
|
||||
windowsAssessments(identity, preservedPath)
|
||||
)
|
||||
|
||||
expect(selection.signingFiles).toHaveLength(4)
|
||||
expect(selection.preservedUpstreamFiles).toEqual([
|
||||
expect.objectContaining({
|
||||
path: preservedPath,
|
||||
action: 'preserve-valid-upstream',
|
||||
signerSubject: 'CN=Microsoft Corporation',
|
||||
signerThumbprint: 'A'.repeat(40)
|
||||
})
|
||||
])
|
||||
expect(selection.verificationFiles).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('rejects invalid Windows signature states and malformed signer identity', () => {
|
||||
const identity = identityFor('win32-arm64')
|
||||
const assessments = windowsAssessments(identity)
|
||||
assessments[0].status = 'hash-mismatch'
|
||||
expect(() => buildSshRelayRuntimeNativeSigningSelection(identity, assessments)).toThrow(
|
||||
/signature status/i
|
||||
)
|
||||
|
||||
const malformedSigner = windowsAssessments(
|
||||
identity,
|
||||
'node_modules/node-pty/build/Release/conpty/OpenConsole.exe'
|
||||
)
|
||||
malformedSigner.find((entry) => entry.status === 'valid-upstream').signerThumbprint = 'bad'
|
||||
expect(() => buildSshRelayRuntimeNativeSigningSelection(identity, malformedSigner)).toThrow(
|
||||
/thumbprint/i
|
||||
)
|
||||
|
||||
const controlSubject = windowsAssessments(
|
||||
identity,
|
||||
'node_modules/node-pty/build/Release/conpty/OpenConsole.exe'
|
||||
)
|
||||
controlSubject.find((entry) => entry.status === 'valid-upstream').signerSubject =
|
||||
'CN=Microsoft\nCorporation'
|
||||
expect(() => buildSshRelayRuntimeNativeSigningSelection(identity, controlSubject)).toThrow(
|
||||
/signer subject/i
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects assessment fields outside the exact status-qualified schema', () => {
|
||||
const identity = identityFor('win32-x64')
|
||||
const unexpected = windowsAssessments(identity)
|
||||
unexpected[0].signerSubject = 'CN=Unexpected'
|
||||
expect(() => buildSshRelayRuntimeNativeSigningSelection(identity, unexpected)).toThrow(
|
||||
/unexpected fields/i
|
||||
)
|
||||
|
||||
const preserved = windowsAssessments(
|
||||
identity,
|
||||
'node_modules/node-pty/build/Release/conpty/OpenConsole.exe'
|
||||
)
|
||||
preserved.find((entry) => entry.status === 'valid-upstream').unexpected = true
|
||||
expect(() => buildSshRelayRuntimeNativeSigningSelection(identity, preserved)).toThrow(
|
||||
/unexpected fields/i
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects missing, duplicate, extra, and hash-mismatched assessments', () => {
|
||||
const identity = identityFor('win32-x64')
|
||||
const assessments = windowsAssessments(identity)
|
||||
expect(() =>
|
||||
buildSshRelayRuntimeNativeSigningSelection(identity, assessments.slice(1))
|
||||
).toThrow(/missing assessment/i)
|
||||
|
||||
expect(() =>
|
||||
buildSshRelayRuntimeNativeSigningSelection(identity, [assessments[0], ...assessments])
|
||||
).toThrow(/duplicate assessment/i)
|
||||
|
||||
expect(() =>
|
||||
buildSshRelayRuntimeNativeSigningSelection(identity, [
|
||||
...assessments,
|
||||
{ path: 'extra.exe', sourceSha256: DIGEST, status: 'unsigned' }
|
||||
])
|
||||
).toThrow(/unexpected assessment/i)
|
||||
|
||||
const mismatched = structuredClone(assessments)
|
||||
mismatched[0].sourceSha256 = `sha256:${'b'.repeat(64)}`
|
||||
expect(() => buildSshRelayRuntimeNativeSigningSelection(identity, mismatched)).toThrow(
|
||||
/source hash/i
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects malformed authenticated size and digest metadata', () => {
|
||||
const invalidSize = identityFor('darwin-x64')
|
||||
invalidSize.entries.find((entry) => entry.role === 'node').size = -1
|
||||
expect(() => buildSshRelayRuntimeNativeSigningSelection(invalidSize, [])).toThrow(/size/i)
|
||||
|
||||
const invalidDigest = identityFor('darwin-x64')
|
||||
invalidDigest.entries.find((entry) => entry.role === 'node').sha256 = 'sha256:nope'
|
||||
expect(() => buildSshRelayRuntimeNativeSigningSelection(invalidDigest, [])).toThrow(/digest/i)
|
||||
})
|
||||
})
|
||||
@@ -82,9 +82,17 @@ describe('SSH relay runtime artifact workflow', () => {
|
||||
expect(source.match(/ssh-relay-runtime-provenance\.test\.mjs/g)).toHaveLength(4)
|
||||
expect(source.match(/ssh-relay-runtime-toolchain\.test\.mjs/g)).toHaveLength(4)
|
||||
expect(source.match(/ssh-relay-runtime-native-signing-plan\.test\.mjs/g)).toHaveLength(4)
|
||||
expect(source.match(/ssh-relay-runtime-native-signing-selection\.test\.mjs/g)).toHaveLength(4)
|
||||
expect(source.match(/ssh-relay-runtime-native-signing-payload\.test\.mjs/g)).toHaveLength(4)
|
||||
expect(
|
||||
source.match(/node --check config\/scripts\/ssh-relay-runtime-native-signing-plan\.mjs/g)
|
||||
).toHaveLength(2)
|
||||
expect(
|
||||
source.match(/node --check config\/scripts\/ssh-relay-runtime-native-signing-selection\.mjs/g)
|
||||
).toHaveLength(2)
|
||||
expect(
|
||||
source.match(/node --check config\/scripts\/ssh-relay-runtime-native-signing-payload\.mjs/g)
|
||||
).toHaveLength(2)
|
||||
expect(
|
||||
source.match(/node --check config\/scripts\/ssh-relay-runtime-closure\.mjs/g)
|
||||
).toHaveLength(2)
|
||||
|
||||
Reference in New Issue
Block a user