Files
orca/config/scripts/ssh-relay-node-tar-inspection.mjs
T
2026-07-14 09:03:25 -04:00

297 lines
9.7 KiB
JavaScript

import { spawn } from 'node:child_process'
import { constants } from 'node:fs'
import { copyFile, mkdir, mkdtemp, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, posix } from 'node:path'
import { pipeline } from 'node:stream/promises'
import { Parser, Unpack } from 'tar'
import { verifySshRelayNodeArchive } from './ssh-relay-node-release-file-verification.mjs'
import { validateSshRelayNodeReleaseContract } from './ssh-relay-node-release-contract.mjs'
const DEFAULT_LIMITS = Object.freeze({
maximumEntries: 100_000,
maximumExpandedBytes: 1024 * 1024 * 1024,
maximumFileBytes: 256 * 1024 * 1024,
maximumDepth: 32,
maximumPathBytes: 512
})
const MAX_XZ_DIAGNOSTIC_BYTES = 64 * 1024
const XZ_TIMEOUT_MS = 5 * 60 * 1000
const ALLOWED_TYPES = new Set(['Directory', 'File', 'SymbolicLink'])
function hasControlOrBackslash(value) {
for (const character of value) {
const code = character.charCodeAt(0)
if (code <= 0x1f || code === 0x7f || character === '\\') {
return true
}
}
return false
}
function archiveRoot(release, tuple) {
const name = release.archives[tuple]?.name
if (typeof name !== 'string' || !name.endsWith('.tar.xz')) {
throw new Error(`Node tuple ${String(tuple)} does not use a tar.xz archive`)
}
return name.slice(0, -'.tar.xz'.length)
}
function portablePath(value, limits, label) {
if (
typeof value !== 'string' ||
value.length === 0 ||
Buffer.byteLength(value) > limits.maximumPathBytes ||
hasControlOrBackslash(value) ||
value.startsWith('/') ||
/^[A-Za-z]:/.test(value)
) {
throw new Error(`${label} is not a bounded portable relative path`)
}
const withoutTrailingSlash = value.endsWith('/') ? value.slice(0, -1) : value
const segments = withoutTrailingSlash.split('/')
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
throw new Error(`${label} contains an unsafe path segment`)
}
return withoutTrailingSlash
}
function validateSymlink(entryPath, linkPath, root, limits) {
if (
typeof linkPath !== 'string' ||
linkPath.length === 0 ||
Buffer.byteLength(linkPath) > limits.maximumPathBytes ||
hasControlOrBackslash(linkPath) ||
linkPath.startsWith('/') ||
/^[A-Za-z]:/.test(linkPath) ||
linkPath.split('/').some((segment) => segment === '' || segment === '.')
) {
throw new Error('Node archive symlink target is not a bounded portable relative path')
}
// Why: official Node npm links use `..`; normalization is safe only inside the signed root.
const resolved = posix.normalize(posix.join(posix.dirname(entryPath), linkPath))
if (resolved !== root && !resolved.startsWith(`${root}/`)) {
throw new Error('Node archive symlink resolves outside its versioned root')
}
}
function createInspectionState(root, limits) {
return {
root,
limits,
seen: new Set(),
entries: 0,
files: 0,
directories: 0,
symlinks: 0,
expandedBytes: 0,
largestFileBytes: 0,
nodeMode: null,
hasLicense: false,
hasNodeHeader: false
}
}
function inspectEntry(entry, state) {
const entryPath = portablePath(entry.path, state.limits, 'Node archive entry path')
if (entryPath !== state.root && !entryPath.startsWith(`${state.root}/`)) {
throw new Error('Node archive entry is outside its exact versioned root')
}
if (entryPath.split('/').length > state.limits.maximumDepth) {
throw new Error('Node archive entry exceeds the nesting-depth limit')
}
if (state.seen.has(entryPath)) {
throw new Error(`Node archive contains a duplicate entry: ${entryPath}`)
}
state.seen.add(entryPath)
state.entries += 1
if (state.entries > state.limits.maximumEntries) {
throw new Error('Node archive exceeds the entry-count limit')
}
if (!ALLOWED_TYPES.has(entry.type)) {
throw new Error(`Node archive contains prohibited entry type: ${String(entry.type)}`)
}
if (entry.type === 'File') {
if (
!Number.isSafeInteger(entry.size) ||
entry.size < 0 ||
entry.size > state.limits.maximumFileBytes
) {
throw new Error('Node archive file exceeds the per-file size limit')
}
state.files += 1
state.expandedBytes += entry.size
state.largestFileBytes = Math.max(state.largestFileBytes, entry.size)
if (state.expandedBytes > state.limits.maximumExpandedBytes) {
throw new Error('Node archive exceeds the expanded-size limit')
}
} else if (entry.type === 'Directory') {
state.directories += 1
} else {
state.symlinks += 1
validateSymlink(entryPath, entry.linkpath, state.root, state.limits)
}
if (entryPath === `${state.root}/bin/node` && entry.type === 'File') {
state.nodeMode = entry.mode
}
if (entryPath === `${state.root}/LICENSE` && entry.type === 'File') {
state.hasLicense = true
}
if (entryPath === `${state.root}/include/node/node.h` && entry.type === 'File') {
state.hasNodeHeader = true
}
}
function inspectionResult(state) {
if (!Number.isSafeInteger(state.nodeMode) || (state.nodeMode & 0o111) === 0) {
throw new Error('Node archive is missing an executable bin/node')
}
if (!state.hasLicense || !state.hasNodeHeader) {
throw new Error('Node archive is missing its license or build headers')
}
return {
root: state.root,
entries: state.entries,
files: state.files,
directories: state.directories,
symlinks: state.symlinks,
expandedBytes: state.expandedBytes,
largestFileBytes: state.largestFileBytes,
nodeMode: state.nodeMode
}
}
export async function inspectSshRelayNodeTarStream(readable, releaseInput, tuple, overrides = {}) {
const release = validateSshRelayNodeReleaseContract(releaseInput)
const limits = { ...DEFAULT_LIMITS, ...overrides }
const state = createInspectionState(archiveRoot(release, tuple), limits)
const parser = new Parser({ strict: true })
parser.on('entry', (entry) => {
try {
inspectEntry(entry, state)
entry.resume()
} catch (error) {
parser.abort(error)
}
})
await pipeline(readable, parser)
return inspectionResult(state)
}
async function pipeXzArchive(archivePath, destination, signal) {
const child = spawn('xz', ['--decompress', '--stdout', '--single-stream', '--', archivePath], {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
signal
})
let stderr = ''
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk) => {
stderr = `${stderr}${chunk}`.slice(-MAX_XZ_DIAGNOSTIC_BYTES)
})
const completion = new Promise((resolve, reject) => {
child.once('error', reject)
child.once('close', (code, closeSignal) => {
if (code === 0) {
resolve()
} else {
reject(new Error(`xz failed (${code ?? closeSignal ?? 'unknown'}): ${stderr.trim()}`))
}
})
})
try {
await Promise.all([pipeline(child.stdout, destination, { signal }), completion])
} catch (error) {
if (child.exitCode === null && child.signalCode === null) {
child.kill()
}
await completion.catch(() => {})
throw error
}
}
async function inspectXzArchive(archivePath, release, tuple, signal) {
const state = createInspectionState(archiveRoot(release, tuple), DEFAULT_LIMITS)
const parser = new Parser({ strict: true })
parser.on('entry', (entry) => {
try {
inspectEntry(entry, state)
entry.resume()
} catch (error) {
parser.abort(error)
}
})
await pipeXzArchive(archivePath, parser, signal)
return inspectionResult(state)
}
function extractionFilter(root, entryPath, entry) {
if (entry.type !== 'File' && entry.type !== 'Directory') {
return false
}
return (
entryPath === root ||
entryPath === `${root}/bin` ||
entryPath === `${root}/bin/node` ||
entryPath === `${root}/LICENSE` ||
entryPath === `${root}/include` ||
entryPath === `${root}/include/node` ||
entryPath.startsWith(`${root}/include/node/`)
)
}
export async function extractVerifiedSshRelayNodeBuildInputs(
releaseInput,
tuple,
sourceArchivePath,
destination,
{ signal } = {}
) {
const release = validateSshRelayNodeReleaseContract(releaseInput)
const effectiveSignal = signal ?? AbortSignal.timeout(XZ_TIMEOUT_MS)
await verifySshRelayNodeArchive(release, tuple, sourceArchivePath)
const stagingDirectory = await mkdtemp(join(tmpdir(), 'orca-node-verified-archive-'))
const stagedArchivePath = join(stagingDirectory, release.archives[tuple].name)
try {
await copyFile(sourceArchivePath, stagedArchivePath, constants.COPYFILE_EXCL)
await verifySshRelayNodeArchive(release, tuple, stagedArchivePath)
const inspection = await inspectXzArchive(stagedArchivePath, release, tuple, effectiveSignal)
await mkdir(destination)
const unpack = new Unpack({
cwd: destination,
strict: true,
preservePaths: false,
filter: (entryPath, entry) => extractionFilter(inspection.root, entryPath, entry)
})
await pipeXzArchive(stagedArchivePath, unpack, effectiveSignal)
const extractedRoot = join(destination, inspection.root)
const nodePath = join(extractedRoot, 'bin', 'node')
const [nodeMetadata, licenseMetadata, headerMetadata] = await Promise.all([
stat(nodePath),
stat(join(extractedRoot, 'LICENSE')),
stat(join(extractedRoot, 'include', 'node', 'node.h'))
])
if (
!nodeMetadata.isFile() ||
(nodeMetadata.mode & 0o111) === 0 ||
!licenseMetadata.isFile() ||
!headerMetadata.isFile()
) {
throw new Error('Extracted Node build inputs do not match the inspected archive contract')
}
return { ...inspection, extractedRoot, nodePath }
} catch (error) {
await rm(destination, { recursive: true, force: true })
throw error
} finally {
await rm(stagingDirectory, { recursive: true, force: true })
}
}
export { DEFAULT_LIMITS }