226 lines
8.7 KiB
JavaScript
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
|
|
}
|