import { readElfProgramHeader } from "../domain/elfProgramHeader.js"; import { MAX_FLUTTER_BUILD_ID_BYTES } from "../domain/flutter/flutterBuildAnalysis.js"; import type { FileHandle } from "node:fs/promises"; import { setImmediate as yieldToEventLoop } from "node:timers/promises"; import { elfNoteAlignment, elfNoteLayout, } from "../domain/native/elfNoteLayout.js"; import { readElfHeader, ELF_HEADER_MAX_BYTES, type ElfParseFailure, } from "./DartElf.js"; const MAX_METADATA_BYTES = 8 * 1024 * 1024; const MAX_BUILD_IDS = 64; // Range bookkeeping and separate positional reads need a budget independent // of payload bytes: tiny/overlapping declarations can otherwise fan out I/O. const MAX_NOTE_RANGES = 1024; export interface ElfBuildIds { readonly candidates: readonly string[]; readonly failures: readonly ElfParseFailure[]; } /** Read only ELF-declared PT_NOTE/SHT_NOTE ranges from one owned engine image. */ export const readElfBuildIds = async ( file: FileHandle, totalBytes: number, signal: AbortSignal | undefined, ): Promise => { const candidates = new Set(); const failures: ElfParseFailure[] = []; const failure = (reason: string): ElfBuildIds => ({ candidates: [...candidates], failures: [...failures, { reason }], }); let examined = 0; const read = async (offset: number, size: number): Promise => { signal?.throwIfAborted(); if ( !Number.isSafeInteger(offset) || !Number.isSafeInteger(size) || offset < 0 || size < 0 || size > totalBytes - offset || size > MAX_METADATA_BYTES - examined ) return null; examined += size; const bytes = Buffer.alloc(size); let copied = 0; while (copied < size) { const result = await file.read( bytes, copied, size - copied, offset + copied, ); signal?.throwIfAborted(); if (result.bytesRead === 0) return null; copied += result.bytesRead; } return bytes; }; const prefix = await read(0, Math.min(totalBytes, ELF_HEADER_MAX_BYTES)); if (prefix === null) return failure("ELF header could not be read"); const parsed = readElfHeader(prefix, totalBytes); if (parsed.failure !== undefined) return failure(parsed.failure.reason); const h = parsed.header; const ranges = new Map< string, { offset: number; size: number; alignment: number } >(); let compressedNotes = false; tables: for (const table of [ { offset: h.e_phoff, width: h.e_phentsize, count: h.e_phnum, program: true, }, { offset: h.e_shoff, width: h.e_shentsize, count: h.e_shnum, program: false, }, ]) { const bytes = await read(table.offset, table.width * table.count); if (bytes === null) return failure( "ELF note tables exceed the 8 MiB metadata budget or are truncated", ); for (let index = 0; index < table.count; index += 1) { if (index % 4096 === 0) { await yieldToEventLoop(); signal?.throwIfAborted(); } const base = index * table.width; const parsedProgram = table.program ? readElfProgramHeader( bytes.subarray(base, base + table.width), h.is64 ? 64 : 32, true, ) : undefined; if (parsedProgram !== undefined && !parsedProgram.ok) return failure(parsedProgram.error.message); const program = parsedProgram?.value; if ( (program?.type ?? bytes.readUInt32LE(base + 4)) !== (table.program ? 4 : 7) ) continue; const offset = program !== undefined ? Number(program.offset) : h.is64 ? Number(bytes.readBigUInt64LE(base + 0x18)) : bytes.readUInt32LE(base + 0x10); const size = program !== undefined ? Number(program.fileSize) : h.is64 ? Number(bytes.readBigUInt64LE(base + 0x20)) : bytes.readUInt32LE(base + 0x14); if ( !Number.isSafeInteger(offset) || !Number.isSafeInteger(size) || size > totalBytes - offset ) return failure("ELF note range is not file-backed"); if (!table.program) { const flags = h.is64 ? bytes.readBigUInt64LE(base + 8) : BigInt(bytes.readUInt32LE(base + 8)); if ((flags & 0x800n) !== 0n) { if (!compressedNotes) { failures.push({ reason: "Compressed ELF note sections are unsupported", }); compressedNotes = true; } continue; } } if (size === 0) continue; const alignment = elfNoteAlignment( program?.alignment ?? (h.is64 ? bytes.readBigUInt64LE(base + 48) : bytes.readUInt32LE(base + 32)), ); const key = `${offset}:${size}:${alignment}`; if (ranges.has(key)) continue; if (ranges.size === MAX_NOTE_RANGES) { failures.push({ reason: "ELF notes exceed the 1024-range note-range inventory/read budget", }); break tables; } ranges.set(key, { offset, size, alignment }); } } for (const range of ranges.values()) { const notes = await read(range.offset, range.size); if (notes === null) return failure( "ELF notes exceed the 8 MiB metadata budget or are truncated", ); let yieldedAt = 0; for (let at = 0; at < notes.length;) { if (at - yieldedAt >= 64 * 1024) { await yieldToEventLoop(); signal?.throwIfAborted(); yieldedAt = at; } if (notes.length - at < 12) { if (notes.subarray(at).some((byte) => byte !== 0)) return failure("ELF note header is truncated"); break; } const namesz = notes.readUInt32LE(at); const descsz = notes.readUInt32LE(at + 4); const type = notes.readUInt32LE(at + 8); const layout = elfNoteLayout(namesz, descsz, range.alignment); const descriptor = at + layout.descriptor; const end = descriptor + descsz; if (end > notes.length) return failure("ELF note descriptor exceeds its declared note range"); if ( namesz === 4 && type === 3 && notes.subarray(at + 12, at + 16).equals(Buffer.from("GNU\0")) ) { if (descsz < 1 || descsz > MAX_FLUTTER_BUILD_ID_BYTES) return failure( `GNU build-id descriptor is outside the 1–${MAX_FLUTTER_BUILD_ID_BYTES} byte projection`, ); const candidate = notes.subarray(descriptor, end).toString("hex"); if (!candidates.has(candidate) && candidates.size === MAX_BUILD_IDS) return failure( "ELF build-id candidates exceed the 64-value evidence budget", ); candidates.add(candidate); } at += layout.next; } } signal?.throwIfAborted(); return { candidates: [...candidates], failures }; };