Files
ycode/scripts/rea/patches/SelectedArtifactText.js

226 lines
8.7 KiB
JavaScript

import { createHash } from 'node:crypto'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
const MAX_SELECTED_FILES = 16
const MAX_FILE_BYTES = 8 * 1024 * 1024
const MAX_TOTAL_BYTES = 16 * 1024 * 1024
const SHA256_PATTERN = /^[a-f0-9]{64}$/u
function assertCanonicalSelectedPath(value, binary = false) {
if (typeof value !== 'string' || value.length === 0) {
throw new TypeError('Selected paths must be non-empty relative strings')
}
if (
value.startsWith('/') ||
value.endsWith('/') ||
value.includes('\\') ||
value.includes('\0') ||
/^[a-zA-Z]:/u.test(value) ||
value.split('/').some((part) => part === '' || part === '.' || part === '..') ||
path.posix.normalize(value) !== value
) {
throw new TypeError(`Selected path is not canonical: ${value}`)
}
if (binary ? !/\.(?:png|jpe?g|gif|webp|svg|woff2?|ttf)$/u.test(value) : !/\.(?:js|css|json|md|txt|yaml|yml|py)$/u.test(value)) {
if (binary) throw new TypeError(`Selected binary path must name a supported image or font: ${value}`)
throw new TypeError(`Selected path must name a supported UTF-8 text file (.js/.css/.json/.md/.txt/.yaml/.yml/.py): ${value}`)
}
}
function validateSelection(expectedRootSha256, selectedPaths, binary = false) {
if (typeof expectedRootSha256 !== 'string' || !SHA256_PATTERN.test(expectedRootSha256)) {
throw new TypeError('Expected ASAR root SHA-256 must be 64 lowercase hexadecimal characters')
}
if (!Array.isArray(selectedPaths) || selectedPaths.length === 0 || selectedPaths.length > MAX_SELECTED_FILES) {
throw new RangeError(`Select between 1 and ${MAX_SELECTED_FILES} exact files`)
}
const paths = [...selectedPaths]
const seen = new Set()
for (const selectedPath of paths) {
assertCanonicalSelectedPath(selectedPath, binary)
if (seen.has(selectedPath)) throw new TypeError(`Duplicate selected path: ${selectedPath}`)
seen.add(selectedPath)
}
return paths
}
async function loadReaRuntime() {
const runtimeRoot = path.resolve(process.env.REA_RUNTIME_ROOT || '.tmp/rea-css-runtime')
const runtimeUrl = (relativePath) => pathToFileURL(path.join(runtimeRoot, relativePath)).href
const [readerModule, evidenceModule] = await Promise.all([
import(runtimeUrl('dist/artifacts/AsarArtifactReader.js')),
import(runtimeUrl('dist/domain/evidence.js')),
])
const { createRequire } = await import('node:module')
const requireFromRuntime = createRequire(path.join(runtimeRoot, 'package.json'))
const packageMetadata = requireFromRuntime('./package.json')
return {
AsarArtifactReader: readerModule.AsarArtifactReader,
createEvidence: evidenceModule.createEvidence,
version: packageMetadata.version,
}
}
async function readSelectedEntry(reader, entry, totalBytes, binary = false) {
if (entry.kind !== 'file') throw new TypeError(`Selected path is not a regular file: ${entry.path}`)
if (!Number.isSafeInteger(entry.declaredSize) || entry.declaredSize < 0) {
throw new Error(`Selected file has no valid declared size: ${entry.path}`)
}
if (entry.declaredSize > MAX_FILE_BYTES) {
throw new RangeError(`Selected file exceeds the ${MAX_FILE_BYTES}-byte per-file budget: ${entry.path}`)
}
if (typeof entry.declaredSha256 !== 'string' || !SHA256_PATTERN.test(entry.declaredSha256)) {
throw new Error(`Selected file has no valid declared SHA-256: ${entry.path}`)
}
const chunks = []
const digest = createHash('sha256')
let bytes = 0
const stream = await reader.open(entry)
for await (const value of stream) {
if (!(Buffer.isBuffer(value) || value instanceof Uint8Array)) {
throw new TypeError(`Selected file stream returned non-byte data: ${entry.path}`)
}
const chunk = Buffer.from(value)
bytes += chunk.length
if (bytes > MAX_FILE_BYTES || bytes > entry.declaredSize || totalBytes + bytes > MAX_TOTAL_BYTES) {
throw new RangeError(`Selected text exceeds its declared size or aggregate budget: ${entry.path}`)
}
digest.update(chunk)
chunks.push(chunk)
}
if (bytes !== entry.declaredSize) {
throw new Error(`Selected file size mismatch for ${entry.path}: declared ${entry.declaredSize}, read ${bytes}`)
}
const actualSha256 = digest.digest('hex')
if (actualSha256 !== entry.declaredSha256) {
throw new Error(`Selected file SHA-256 mismatch for ${entry.path}`)
}
if (binary) return { path: entry.path, size_bytes: bytes, sha256: actualSha256, unpacked: entry.unpacked,
encoding: 'base64', data_base64: Buffer.concat(chunks, bytes).toString('base64') }
let text
try {
text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks, bytes))
} catch (cause) {
throw new Error(`Selected file is not strict UTF-8: ${entry.path}`, { cause })
}
return {
path: entry.path,
size_bytes: bytes,
sha256: actualSha256,
unpacked: entry.unpacked,
text,
}
}
/** Export bounded UTF-8 text from exact selected files in a root-pinned ASAR. */
export async function exportSelectedArtifactText(inputPath, expectedRootSha256, selectedPaths, binary = false) {
if (typeof inputPath !== 'string' || inputPath.length === 0) {
throw new TypeError('ASAR input path must be a non-empty string')
}
const paths = validateSelection(expectedRootSha256, selectedPaths, binary)
const { AsarArtifactReader, createEvidence, version } = await loadReaRuntime()
const reader = new AsarArtifactReader(inputPath)
let primaryFailure
let primaryFailed = false
let evidence
try {
await reader.prepareContainer(expectedRootSha256)
const wanted = new Set(paths)
const entries = new Map()
for await (const entry of reader.entries()) {
if (!wanted.has(entry.path)) continue
if (entries.has(entry.path)) throw new Error(`ASAR inventory contains a duplicate path: ${entry.path}`)
entries.set(entry.path, entry)
}
for (const selectedPath of paths) {
if (!entries.has(selectedPath)) throw new Error(`Selected path is absent from the ASAR inventory: ${selectedPath}`)
}
let declaredTotalBytes = 0
for (const selectedPath of paths) {
const entry = entries.get(selectedPath)
if (!Number.isSafeInteger(entry.declaredSize) || entry.declaredSize < 0) {
throw new Error(`Selected file has no valid declared size: ${selectedPath}`)
}
if (entry.declaredSize > MAX_FILE_BYTES) {
throw new RangeError(`Selected file exceeds the ${MAX_FILE_BYTES}-byte per-file budget: ${selectedPath}`)
}
declaredTotalBytes += entry.declaredSize
if (declaredTotalBytes > MAX_TOTAL_BYTES) {
throw new RangeError(`Selected files exceed the ${MAX_TOTAL_BYTES}-byte aggregate budget`)
}
}
const files = []
let actualTotalBytes = 0
for (const selectedPath of paths) {
const file = await readSelectedEntry(reader, entries.get(selectedPath), actualTotalBytes, binary)
actualTotalBytes += file.size_bytes
files.push(file)
}
const unselectedScope = 'Not read; unpacked payload content and availability are unknown.'
evidence = createEvidence(
{ path: inputPath, sha256: expectedRootSha256, format: 'asar' },
{
id: binary ? 'rea-selected-asar-binary' : 'rea-selected-asar-text',
name: binary ? 'REA selected ASAR binary adapter' : 'REA selected ASAR text adapter',
version,
},
{
predicateType: binary ? 'rea.artifact.selected-binary' : 'rea.artifact.selected-text',
operation: binary ? 'export_selected_asar_artifact_binary' : 'export_selected_asar_artifact_text',
parameters: {
expected_root_sha256: expectedRootSha256,
selected_paths: paths,
max_file_bytes: MAX_FILE_BYTES,
max_total_bytes: MAX_TOTAL_BYTES,
},
result: {
root_sha256: expectedRootSha256,
scope: {
kind: 'selected-files-only',
selected_paths: paths,
unselected_members: unselectedScope,
},
files,
},
confidence: 'observed',
authority: 'shipped-artifact',
limitations: [
'Only selected file streams were read and validated.',
`Unselected ASAR members were not opened; ${unselectedScope}`,
],
locations: paths.map((selectedPath) => ({ kind: 'artifact-path', path: selectedPath })),
},
)
} catch (error) {
primaryFailure = error
primaryFailed = true
}
try {
await reader.close()
} catch (cleanupFailure) {
if (primaryFailed) {
throw new AggregateError(
[primaryFailure, cleanupFailure],
'Selected ASAR text export failed and reader cleanup also failed',
{ cause: primaryFailure },
)
}
throw cleanupFailure
}
if (primaryFailed) throw primaryFailure
return evidence
}