import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js"; import { createHash } from "node:crypto"; import { chmod, mkdtemp, open as openFile, rm, stat } from "node:fs/promises"; import { createReadStream } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { setImmediate as yieldToEventLoop } from "node:timers/promises"; import type { ExecutionOptions, ProviderAvailability, } from "../application/AnalysisProvider.js"; import { createAnalysisExecution, type AnalysisExecution, } from "../application/AnalysisProvider.js"; import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js"; import { MAX_FLUTTER_LIBRARY_PATH_BYTES, type FlutterRequest, } from "../domain/flutter/flutterBuildAnalysis.js"; import { AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisCancelledError, AnalysisInputError, AnalysisResourceConstraintError, AnalysisUnsupportedTargetError, } from "../domain/analysisErrorCore.js"; import { AnalysisProtocolError } from "../domain/analysisErrorCore.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { err, ok, type Result } from "../domain/result.js"; import { ArtifactReaderFailure, type ArtifactEntry, } from "../artifacts/ArtifactReader.js"; import { ZipArtifactReader } from "../artifacts/ZipArtifactReader.js"; import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { NonRegularFileReadError, openRegularFile, RegularFileAdmissionFailure, sameRegularFileState, } from "../filesystem/RegularFile.js"; import { MAX_SNAPSHOT_HASH_CANDIDATES, LabeledStringScanner, SnapshotHashScanner, } from "./FlutterPayloadScan.js"; import { ELF_HEADER_MAX_BYTES, readElfHeader, readElfSymbols, readSnapshotHeader, } from "./DartElf.js"; import { readElfBuildIds } from "./ElfBuildId.js"; import { privateWorkspaceFailure } from "../process/privateWorkspaceFailure.js"; /** Stable identity for the Flutter build-identification provider. */ export const FLUTTER_PROVIDER_IDENTITY = { id: "flutter", name: "Flutter build identification", version: null, } as const; const APK_METADATA_READ_MAX_BYTES = 8 * 1024 * 1024; const LIBRARY_MAX_BYTES = 256 * 1024 * 1024; /** Cumulative cap across every library entry in one identification. */ const TOTAL_EXTRACTION_MAX_BYTES = 768 * 1024 * 1024; /** The APK itself must fit within this budget before any copy. */ const APK_MAX_BYTES = 2 * 1024 * 1024 * 1024; // At most 32 ABI projections (two libraries each). With bounded hashes and // engine labels this keeps expanded result metadata below 512 KiB for ordinary // ABI names, independently of the archive's compressed-byte budget. const MAX_FLUTTER_ABIS = 32; const flutterBudget = (allowance: string, kind: "parser" | "input") => fixedAllowanceOptions(allowance, kind); const MAX_POOL_URIS = 500; const MAX_POOL_TOKENS = 2_000; /** A complete AOT image carries exactly one VM and one isolate section. */ const SYMBOL_PAIR_COUNT = 2; const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/u; /** * Stream a local file digest without retaining large files in memory. * Aborts between chunks so a cancellation cannot run a digest to completion. */ const sha256File = async ( operation: string, path: string, signal: AbortSignal | undefined, ): Promise => { const hash = createHash("sha256"); const stream = createReadStream(path, signal === undefined ? {} : { signal }); for await (const chunk of stream) { if (signal?.aborted === true) throw new AnalysisCancelledError(operation, { cause: signal.reason }); hash.update(chunk as Buffer); } return hash.digest("hex"); }; /** One owned snapshot root: a read-only copy of the admitted input. */ interface OwnedSnapshot { readonly root: string; readonly path: string; readonly bytes: number; readonly sha256: string; } interface LibraryFacts { readonly libapp: { present: boolean; bytes: number | null; sha256: string | null; elfHeaderValid: boolean | null; sources: number; candidates: readonly string[]; }; readonly libflutter: { present: boolean; bytes: number | null; sha256: string | null; buildId: string | null; buildIdCandidates: readonly string[]; buildIdFailures: readonly string[]; dartVersion: string | null; dartVersionCapped: boolean; toolchainLines: readonly string[]; toolchainCapped: boolean; }; } const emptyFacts = (): LibraryFacts => ({ libapp: { present: false, bytes: null, sha256: null, elfHeaderValid: null, sources: 0, candidates: [], }, libflutter: { present: false, bytes: null, sha256: null, buildId: null, buildIdCandidates: [], buildIdFailures: [], dartVersion: null, dartVersionCapped: false, toolchainLines: [], toolchainCapped: false, }, }); /** Translate a ZIP reader rejection into the typed analysis error algebra. */ const isCancellation = (cause: unknown): boolean => cause instanceof AnalysisCancelledError || (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") || (cause instanceof Error && cause.name === "AbortError") || (typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ABORT_ERR"); const mapInputFailure = ( operation: string, path: string, cause: unknown, ): AnalysisError => { if (cause instanceof AnalysisError) return cause; const cancellation = regularFileCancellation(cause); if (cancellation !== undefined) return new AnalysisCancelledError(operation, { cause: cancellation.reason, }); if (isCancellation(cause)) return new AnalysisCancelledError(operation, { cause }); if ( cause instanceof Error && "code" in cause && (cause.code === "EACCES" || cause.code === "EPERM") ) return new AnalysisAccessDeniedError(operation, path, cause.code, { cause, }); if (cause instanceof ArtifactReaderFailure && cause.reason === "limit") return new AnalysisResourceConstraintError( operation, "memory", cause.message, { max_metadata_read_bytes: APK_METADATA_READ_MAX_BYTES }, { cause, ...flutterBudget("The Flutter APK metadata read allowance", "parser"), }, ); return new AnalysisInputError(operation, { cause }, [ { path: ["path"], reason: "invalid_format", message: inputFailureMessage(path, cause), }, ]); }; /** Name a missing or non-file selection instead of blaming package bytes. */ const inputFailureMessage = (path: string, cause: unknown): string => { if ( cause instanceof Error && "code" in cause && (cause.code === "ENOENT" || cause.code === "ENOTDIR") ) return `Selected APK does not exist: ${path}`; if (cause instanceof NonRegularFileReadError) return `Selected APK is not a regular file: ${path}`; const message = cause instanceof Error ? cause.message : String(cause); return `The APK could not be read as a package: ${path}: ${message}`; }; /** * Provider-owned storage failures must never be attributed to caller input. * `workspacePath` is the owned root once allocated, so a failure without its * own errno path still names the workspace it occurred in. */ const mapOwnedIoFailure = ( operation: string, cause: unknown, workspacePath: string | null, ): AnalysisError => { if (cause instanceof AnalysisError) return cause; const cancellation = regularFileCancellation(cause); if (cancellation !== undefined) return new AnalysisCancelledError(operation, { cause: cancellation.reason, }); if (isCancellation(cause)) return new AnalysisCancelledError(operation, { cause }); if ( cause instanceof Error && "code" in cause && typeof cause.code === "string" ) return privateWorkspaceFailure( FLUTTER_PROVIDER_IDENTITY.id, operation, cause, workspacePath, ); return new AnalysisProtocolError(operation, { cause }); }; /** * ZIP format failures describe caller bytes. A system error comes from the * provider-owned snapshot descriptor, so it propagates unchanged to the * snapshot owner, which maps it against the owned workspace. */ const mapPackageFailure = ( operation: string, path: string, cause: unknown, ): unknown => cause instanceof Error && "code" in cause && typeof cause.code === "string" ? cause : mapInputFailure(operation, path, cause); interface FlutterLibrary { readonly entry: ArtifactEntry; readonly abi: string; readonly kind: "libapp" | "libflutter"; } /** One inventory owns occurrence ambiguity and metadata expansion for both tools. */ const collectFlutterLibraries = async ( reader: ZipArtifactReader, operation: string, path: string, signal: AbortSignal | undefined, ): Promise => { const libraries: FlutterLibrary[] = []; const paths = new Set(); const abis = new Set(); try { for await (const entry of reader.entries(signal)) { if (entry.kind !== "file") continue; const match = /^lib\/([^/]+)\/(libapp|libflutter)\.so$/u.exec(entry.path); if (match === null) continue; if (paths.has(entry.path)) throw new AnalysisInputError(operation, undefined, [ { path: ["path"], reason: "invalid_format", message: `The selected APK has duplicate Flutter library members: ${path}: ${entry.path}`, }, ]); paths.add(entry.path); abis.add(match[1]!); if ( abis.size > MAX_FLUTTER_ABIS || Buffer.byteLength(entry.path) > MAX_FLUTTER_LIBRARY_PATH_BYTES ) throw new AnalysisResourceConstraintError( operation, "memory", "Flutter library inventory exceeds the bounded ABI projection budget", { max_flutter_abis: MAX_FLUTTER_ABIS, max_library_path_bytes: MAX_FLUTTER_LIBRARY_PATH_BYTES, }, flutterBudget("The Flutter ABI inventory allowance", "parser"), ); libraries.push({ entry, abi: match[1]!, kind: match[2] === "libapp" ? "libapp" : "libflutter", }); } return libraries; } catch (cause) { throw mapPackageFailure(operation, path, cause); } }; /** The generator owns iterator closure, including early extraction failures. */ async function* openEntrySafely( reader: ZipArtifactReader, entry: ArtifactEntry, operation: string, path: string, signal: AbortSignal | undefined, ): AsyncGenerator { try { yield* await reader.open(entry, signal); } catch (cause) { throw mapPackageFailure(operation, path, cause); } } const checkApkSize = (operation: string, bytes: number): void => { if (bytes > APK_MAX_BYTES) throw new AnalysisResourceConstraintError( operation, "file-size", `The selected APK exceeds the ${String(APK_MAX_BYTES)}-byte snapshot budget`, { max_apk_bytes: APK_MAX_BYTES, observed_bytes: bytes }, flutterBudget("the Flutter APK snapshot allowance", "input"), ); }; export interface FlutterProviderOptions { /** Deterministic root removal for tests; production removes recursively. */ readonly removeRoot?: (path: string) => Promise; } /** * Flutter build identification by pure parsing: reads `lib//libapp.so` * and `libflutter.so` through the hardened ZIP reader and reports the Dart * snapshot hash, GNU build-id, and any labeled version strings verbatim. * Runs no engine, installs nothing, and performs no external lookups. */ export class FlutterBuildProvider { private readonly ownedRoots = new Map< string, Set<{ close(): Promise }> >(); private readonly removeRoot: (path: string) => Promise; private readonly cleanupFailures = new Map(); constructor(options: FlutterProviderOptions = {}) { this.removeRoot = options.removeRoot ?? ((path) => rm(path, { recursive: true, force: true })); } /** * Snapshot the admitted input into a provider-owned, read-only copy so a * concurrently modified original cannot mix into one observation. The * digest is taken from the snapshot, and every later read uses the * snapshot path. */ private async ownSnapshot( operation: string, path: string, signal: AbortSignal | undefined, ): Promise { let root: string; try { root = await mkdtemp(join(tmpdir(), "rea-flutter-")); } catch (cause) { // No workspace exists yet; the errno path names the attempted template. throw mapOwnedIoFailure(operation, cause, null); } this.ownedRoots.set(root, new Set()); const snapshotPath = join(root, "input.apk"); const readSource = async (read: () => Promise): Promise => { try { return await read(); } catch (cause) { throw mapInputFailure(operation, path, cause); } }; try { // Pin the admitted source: capture its identity before the copy and // revalidate after, so an in-place replacement or swap mid-copy is // refused instead of publishing mixed bytes under the original path. // The copy itself is streamed and abort-checked so cancellation // stops the write promptly instead of draining the whole file. let source: OwnedFileHandle; try { source = this.ownResource( root, new OwnedFileHandle( await openRegularFile(path, { symlinks: "follow", signal }), ), ); } catch (cause) { if (cause instanceof RegularFileAdmissionFailure) { this.ownResource(root, cause.owner); throw mapInputFailure(operation, path, cause.cause); } throw mapInputFailure(operation, path, cause); } const before = await readSource(() => source.handle.stat({ bigint: true }), ); checkApkSize(operation, Number(before.size)); const output = this.ownResource( root, new OwnedFileHandle(await openFile(snapshotPath, "wx")), ); const chunk = Buffer.alloc(64 * 1024); let copiedBytes = 0; while (copiedBytes <= Number(before.size)) { signal?.throwIfAborted(); const { bytesRead } = await readSource(() => source.handle.read( chunk, 0, Math.min(chunk.length, Number(before.size) - copiedBytes + 1), copiedBytes, ), ); signal?.throwIfAborted(); if (bytesRead === 0) break; copiedBytes += bytesRead; if (BigInt(copiedBytes) > before.size) throw new AnalysisArtifactChangedError( operation, path, "Selected file grew during snapshotting", ); await output.handle.writeFile(chunk.subarray(0, bytesRead)); } await this.releaseResource(root, output); const [after, currentPath] = await Promise.all([ readSource(() => source.handle.stat({ bigint: true })), stat(path, { bigint: true }).catch((cause: unknown) => { if ( cause instanceof Error && "code" in cause && (cause.code === "EACCES" || cause.code === "EPERM") ) throw mapInputFailure(operation, path, cause); throw new AnalysisArtifactChangedError( operation, path, "Selected path became unavailable during snapshotting", { cause }, ); }), ]); if ( BigInt(copiedBytes) !== before.size || !sameRegularFileState(before, after) || !sameRegularFileState(before, currentPath) ) throw new AnalysisArtifactChangedError( operation, path, "Selected file identity or contents changed during snapshotting", ); await this.releaseResource(root, source); await chmod(snapshotPath, 0o400); const info = await stat(snapshotPath); const sha256 = await sha256File(operation, snapshotPath, signal); return { root, path: snapshotPath, bytes: info.size, sha256 }; } catch (cause) { await this.discardRoot(root); if (signal?.aborted === true) throw new AnalysisCancelledError(operation, { cause }); throw mapOwnedIoFailure(operation, cause, root); } } private readerFor(snapshot: OwnedSnapshot): ZipArtifactReader { return this.ownResource( snapshot.root, new ZipArtifactReader(snapshot.path, "apk", APK_METADATA_READ_MAX_BYTES), ); } private ownResource }>( root: string, resource: T, ): T { this.ownedRoots.get(root)!.add(resource); return resource; } private async releaseResource( root: string, resource: { close(): Promise }, ): Promise { await resource.close(); this.ownedRoots.get(root)?.delete(resource); } /** Close every owned handle before removing its root; retain any failure. */ private async discardRoot(root: string): Promise { const resources = this.ownedRoots.get(root); if (resources === undefined) return true; const attempts = await Promise.allSettled( [...resources].map((resource) => this.releaseResource(root, resource)), ); const failures = attempts.flatMap((attempt) => attempt.status === "rejected" ? [attempt.reason as unknown] : [], ); if (failures.length > 0) { this.cleanupFailures.set(root, failures); return false; } try { await this.removeRoot(root); this.ownedRoots.delete(root); this.cleanupFailures.delete(root); return true; } catch (cause) { this.cleanupFailures.set(root, [cause]); return false; } } /** Pure parsing; the provider is always available. */ async inspectAvailability(): Promise { return { status: "available", code: null, reason: null, diagnostics: { engine: "static-apk-parsing" }, }; } /** Retain failed snapshot cleanup ownership and retry it on close. */ async close(): Promise { const failures: { readonly root: string; readonly causes: readonly unknown[]; }[] = []; for (const root of [...this.ownedRoots.keys()]) { if (!(await this.discardRoot(root))) failures.push({ root, causes: this.cleanupFailures.get(root)! }); } if (failures.length > 0) throw new ProviderCleanupError( FLUTTER_PROVIDER_IDENTITY.id, failures.map(({ root }) => root), { reason: failures .flatMap(({ causes }) => causes.map((cause) => cause instanceof Error ? cause.message : String(cause), ), ) .join("; "), retained_roots: failures.map(({ root, causes }) => ({ path: root, failures: causes.map((cause) => ({ name: cause instanceof Error ? cause.name : "UnknownError", message: cause instanceof Error ? cause.message : String(cause), ...(cause instanceof Error && "code" in cause && typeof cause.code === "string" ? { code: cause.code } : {}), })), })), }, { cause: new AggregateError( failures.flatMap(({ causes }) => causes), "Flutter snapshot cleanup failed", ), operation: "close_flutter_build", }, ); } async execute( request: FlutterRequest, options?: ExecutionOptions, ): Promise> { if (options?.signal?.aborted === true) return err( new AnalysisCancelledError(request.operation, { cause: options.signal.reason, }), ); const { path } = request.input; const signal = options?.signal; let snapshot: OwnedSnapshot | undefined; let result: Result; try { snapshot = await this.ownSnapshot(request.operation, path, signal); const reader = this.readerFor(snapshot); const libraries = await collectFlutterLibraries( reader, request.operation, path, signal, ); result = request.operation === "inspect_dart_aot" ? await this.inspectDartAot( path, request.input.abi, signal, snapshot, reader, libraries, ) : await this.identifyFlutterBuild( path, signal, snapshot, reader, libraries, ); } catch (cause) { result = err( mapOwnedIoFailure(request.operation, cause, snapshot?.root ?? null), ); } finally { if (snapshot !== undefined) await this.discardRoot(snapshot.root); } // Cancellation observed after the awaited snapshot removal still keeps // the completed facts, composed exactly as the uncancelled Evidence. if (signal?.aborted === true) return err( new AnalysisCancelledError(request.operation, { cause: signal.reason, ...(result.ok ? { partialObservation: createAnalysisExecutionEvidence( request.operation, request.input, result.value, "observed", ), } : {}), }), ); return result; } private async identifyFlutterBuild( path: string, signal: AbortSignal | undefined, snapshot: OwnedSnapshot, reader: ZipArtifactReader, flutterEntries: readonly FlutterLibrary[], ): Promise> { const operation = "identify_flutter_build"; for (const { entry } of flutterEntries) { if (entry.declaredSize !== null && entry.declaredSize > LIBRARY_MAX_BYTES) return err( new AnalysisResourceConstraintError( operation, "file-size", `${entry.path} exceeds the library read budget`, { max_library_bytes: LIBRARY_MAX_BYTES, entry_path: entry.path }, flutterBudget("the Flutter library read allowance", "input"), ), ); } // Preflight: refuse before any I/O when the declared entry sizes // already exceed the cumulative budget, even though streaming might // discover smaller actual sizes. const declaredTotal = flutterEntries.reduce( (total, { entry }) => total + (entry.declaredSize ?? 0), 0, ); if (declaredTotal > TOTAL_EXTRACTION_MAX_BYTES) return err( new AnalysisResourceConstraintError( operation, "file-size", `declared library sizes total ${String(declaredTotal)} bytes, exceeding the ${String(TOTAL_EXTRACTION_MAX_BYTES)}-byte cumulative budget before any extraction`, { max_total_bytes: TOTAL_EXTRACTION_MAX_BYTES, declared_total: declaredTotal, }, flutterBudget("the Flutter cumulative extraction allowance", "input"), ), ); const facts = new Map(); let extractedTotal = 0; let libraryIndex = 0; for (const { entry, abi, kind } of flutterEntries) { const stream = openEntrySafely(reader, entry, operation, path, signal); const hash = createHash("sha256"); const snapshotScanner = new SnapshotHashScanner(); const labeledScanner = new LabeledStringScanner(); const engineFile = kind === "libflutter" ? this.ownResource( snapshot.root, new OwnedFileHandle( await openFile( join(snapshot.root, `engine-${libraryIndex++}.so`), "wx+", ), ), ) : null; let bytes = 0; const headerPrefix = Buffer.alloc(ELF_HEADER_MAX_BYTES); let headerBytes = 0; for await (const chunk of stream) { if (signal?.aborted === true) return err( new AnalysisCancelledError(operation, { cause: signal.reason, }), ); const view = chunk as Buffer; headerBytes += view.copy( headerPrefix, headerBytes, 0, Math.min(view.length, headerPrefix.length - headerBytes), ); bytes += view.byteLength; if (bytes > LIBRARY_MAX_BYTES) return err( new AnalysisResourceConstraintError( operation, "file-size", `${entry.path} exceeds the ${String(LIBRARY_MAX_BYTES)}-byte read budget while streaming`, { max_library_bytes: LIBRARY_MAX_BYTES, entry_path: entry.path, }, flutterBudget("the Flutter library read allowance", "input"), ), ); hash.update(view); if (kind === "libapp") snapshotScanner.push(view); else { labeledScanner.push(view); await engineFile!.handle.writeFile(view); } // Enforce the cumulative cap inside the chunk loop so refusal // happens before the remaining bytes are streamed and hashed. if (extractedTotal + bytes > TOTAL_EXTRACTION_MAX_BYTES) return err( new AnalysisResourceConstraintError( operation, "file-size", `cumulative library extraction across ${String(flutterEntries.length)} entries exceeds the ${String(TOTAL_EXTRACTION_MAX_BYTES)}-byte budget`, { max_total_bytes: TOTAL_EXTRACTION_MAX_BYTES }, flutterBudget( "the Flutter cumulative extraction allowance", "input", ), ), ); } extractedTotal += bytes; const digest = hash.digest("hex"); const current = facts.get(abi) ?? emptyFacts(); if (kind === "libapp") { const scan = snapshotScanner.result(); if (scan.capped) return err( new AnalysisResourceConstraintError( operation, "memory", "Snapshot hash candidates exceed the retained evidence budget", { max_snapshot_hash_candidates: MAX_SNAPSHOT_HASH_CANDIDATES, entry_path: entry.path, }, flutterBudget( "The Flutter snapshot-hash candidate allowance", "parser", ), ), ); facts.set(abi, { ...current, libapp: { present: true, bytes, sha256: digest, elfHeaderValid: readElfHeader(headerPrefix.subarray(0, headerBytes), bytes) .header !== undefined, sources: scan.sources, candidates: scan.candidates, }, }); } else { const labeled = labeledScanner.result(); const buildIds = await readElfBuildIds( engineFile!.handle, bytes, signal, ); await this.releaseResource(snapshot.root, engineFile!); facts.set(abi, { ...current, libflutter: { present: true, bytes, sha256: digest, buildId: buildIds.failures.length === 0 && buildIds.candidates.length === 1 ? buildIds.candidates[0]! : null, buildIdCandidates: buildIds.candidates, buildIdFailures: buildIds.failures.map((failure) => failure.reason), ...labeled, }, }); } } const abis = [...facts.entries()] .map(([abi, value]) => ({ abi, libapp: { present: value.libapp.present, bytes: value.libapp.bytes, sha256: value.libapp.sha256, elf_header_valid: value.libapp.elfHeaderValid, snapshot_hash_sources: value.libapp.sources, snapshot_hash_candidates: [...value.libapp.candidates], snapshot_hash: value.libapp.candidates.length === 1 ? value.libapp.candidates[0]! : null, }, libflutter: { present: value.libflutter.present, bytes: value.libflutter.bytes, sha256: value.libflutter.sha256, build_id: value.libflutter.buildId, build_id_candidates: [...value.libflutter.buildIdCandidates], dart_version: value.libflutter.dartVersion, dart_version_capped: value.libflutter.dartVersionCapped, toolchain_lines: [...value.libflutter.toolchainLines], toolchain_capped: value.libflutter.toolchainCapped, }, })) .sort((left, right) => compareUnicodeCodePoints(left.abi, right.abi)); const flutterDetected = abis.some((entry) => entry.libapp.present); const partial = abis.some( (entry) => (entry.libapp.present && (entry.libapp.snapshot_hash === null || entry.libapp.snapshot_hash_sources !== 2 || entry.libapp.elf_header_valid !== true)) || entry.libflutter.toolchain_capped || entry.libflutter.dart_version_capped || entry.libapp.present !== entry.libflutter.present, ) || [...facts.values()].some( (value) => value.libflutter.buildIdFailures.length > 0 || value.libflutter.buildIdCandidates.length > 1, ); return ok( createAnalysisExecution( { target: { path, bytes: snapshot.bytes, sha256: snapshot.sha256 }, flutter_detected: flutterDetected, abis, coverage: partial ? "partial" : "complete", }, FLUTTER_PROVIDER_IDENTITY, { subject: { path, format: "apk", sha256: snapshot.sha256 }, locations: [{ kind: "artifact-path", path }], limitations: [ "The APK is read through an owned read-only snapshot; concurrent changes to the original cannot mix into this observation", "The snapshot hash identifies a Dart SDK build only against an external mapping dataset; REA performs no lookup", "Release engine builds often carry no labeled Dart version string; absence is reported, not guessed", "Engine build IDs come only from ELF-declared PT_NOTE/SHT_NOTE records; conflicting or incompletely examined notes preserve candidates and report partial coverage", ...[...facts].flatMap(([abi, value]) => [ ...value.libflutter.buildIdFailures.map( (reason) => `lib/${abi}/libflutter.so: ${reason}`, ), ...(value.libflutter.buildIdCandidates.length > 1 ? [`lib/${abi}/libflutter.so has conflicting GNU build IDs`] : []), ]), ...abis .filter((entry) => entry.libflutter.toolchain_capped) .map( (entry) => `lib/${entry.abi}/libflutter.so has toolchain labels omitted by the complete-label grammar or the 4-line projection`, ), ...abis .filter((entry) => entry.libflutter.dart_version_capped) .map( (entry) => `lib/${entry.abi}/libflutter.so has Dart version labels omitted by the complete-label grammar or the single-version projection`, ), "The snapshot hash sits 20 bytes after each snapshot magic in the AOT payload; findings are observations of that layout", "ELF header validity checks the supported header layout and declared table extents; identification does not resolve symbols or validate table contents", ...abis .filter( (entry) => entry.libapp.present && entry.libapp.snapshot_hash_sources !== 2, ) .map( (entry) => `lib/${entry.abi}/libapp.so carries ${String(entry.libapp.snapshot_hash_sources)} snapshot section(s); a complete AOT payload carries two, so the library is truncated or nonstandard`, ), ...abis .filter( (entry) => entry.libapp.present && entry.libapp.elf_header_valid !== true, ) .map( (entry) => `lib/${entry.abi}/libapp.so has a truncated, malformed, or unsupported ELF header or table extent; byte-scanned hashes remain observations only`, ), ...(flutterDetected ? [] : [ "No lib/*/libapp.so was found; the target does not carry a Flutter AOT payload", ]), ], }, ), ); } private async inspectDartAot( path: string, abi: string | undefined, signal: AbortSignal | undefined, snapshot: OwnedSnapshot, reader: ZipArtifactReader, libraries: readonly FlutterLibrary[], ): Promise> { const candidates = libraries.filter((library) => library.kind === "libapp"); if (candidates.length === 0) return err( new AnalysisUnsupportedTargetError( "inspect_dart_aot", path, "No lib//libapp.so was found; the target carries no Dart AOT payload", ), ); const preference = ["arm64-v8a", "x86_64", "x86", "armeabi-v7a"]; const selected = abi === undefined ? [...candidates].sort( (left, right) => (preference.indexOf(left.abi) + 1 || preference.length + 1) - (preference.indexOf(right.abi) + 1 || preference.length + 1) || compareUnicodeCodePoints(left.abi, right.abi), )[0]! : candidates.find((candidate) => candidate.abi === abi); if (selected === undefined) return err( new AnalysisUnsupportedTargetError( "inspect_dart_aot", path, `No libapp.so was found for ABI ${abi ?? "(none)"}; available: ${candidates .map((candidate) => candidate.abi) .join(", ")}`, ), ); const declared = selected.entry.declaredSize ?? LIBRARY_MAX_BYTES + 1; if (declared > LIBRARY_MAX_BYTES) return err( new AnalysisResourceConstraintError( "inspect_dart_aot", "file-size", `lib/${selected.abi}/libapp.so declares ${String(declared)} bytes, exceeding the ${String(LIBRARY_MAX_BYTES)}-byte read budget`, { max_library_bytes: LIBRARY_MAX_BYTES }, flutterBudget("the Flutter library read allowance", "input"), ), ); const imagePath = join(snapshot.root, "libapp.so"); const imageHash = createHash("sha256"); const imageWriter = this.ownResource( snapshot.root, new OwnedFileHandle(await openFile(imagePath, "wx")), ); { const stream = openEntrySafely( reader, selected.entry, "inspect_dart_aot", path, signal, ); let total = 0; for await (const chunk of stream) { if (signal?.aborted === true) return err(new AnalysisCancelledError("inspect_dart_aot")); const view = chunk as Buffer; total += view.byteLength; if (total > LIBRARY_MAX_BYTES) return err( new AnalysisResourceConstraintError( "inspect_dart_aot", "file-size", `lib/${selected.abi}/libapp.so exceeds the ${String(LIBRARY_MAX_BYTES)}-byte read budget while streaming`, { max_library_bytes: LIBRARY_MAX_BYTES }, flutterBudget("the Flutter library read allowance", "input"), ), ); imageHash.update(view); await imageWriter.handle.writeFile(view); } } await this.releaseResource(snapshot.root, imageWriter); const imageSha256 = imageHash.digest("hex"); // Allocate exactly the budget-checked size before reading, so the // buffer cannot exceed the declared library size plus one byte. const image = Buffer.alloc(declared + 1); let imageLength = 0; { const file = this.ownResource( snapshot.root, new OwnedFileHandle(await openFile(imagePath, "r")), ); while (imageLength < image.length) { if (signal?.aborted === true) return err(new AnalysisCancelledError("inspect_dart_aot")); const result = await file.handle.read( image, imageLength, image.length - imageLength, null, ); signal?.throwIfAborted(); if (result.bytesRead === 0) break; imageLength += result.bytesRead; } await this.releaseResource(snapshot.root, file); } signal?.throwIfAborted(); const bounded = image.subarray(0, imageLength); const symbolNames = [ "_kDartVmSnapshotData", "_kDartVmSnapshotInstructions", "_kDartIsolateSnapshotData", "_kDartIsolateSnapshotInstructions", ]; const sections: { name: string; offset: number; size: number; magic_valid: boolean; kind: number | null; header_length: number | null; }[] = []; const dataHeaders: { name: string; offset: number; size: number; header: ReturnType; }[] = []; let isolateData: Buffer | null = null; const inventory = await readElfSymbols(bounded, symbolNames, signal); for (const symbol of inventory.symbols) { const name = symbol.name; const header = name.endsWith("Data") ? readSnapshotHeader( bounded.subarray(symbol.offset, symbol.offset + symbol.size), 0, ) : null; sections.push({ name, offset: symbol.offset, size: symbol.size, magic_valid: header?.magicValid ?? false, kind: header?.kind ?? null, header_length: header?.headerLength ?? null, }); if (header !== null) dataHeaders.push({ ...symbol, header }); if (name === "_kDartIsolateSnapshotData") isolateData = image.subarray( symbol.offset, symbol.offset + symbol.size, ); } // Each exported data symbol owns one header at its start. Matching // records elsewhere in a symbol are not evidence for the other // symbol's hash. Count a physical header only once for aliased exports. const observedOffsets = new Set(); let aliasedDataSymbols = false; const hashes: string[] = []; for (const section of dataHeaders) { if (observedOffsets.has(section.offset)) { aliasedDataSymbols = true; continue; } observedOffsets.add(section.offset); if (section.header.hash !== null) hashes.push(section.header.hash); } const hashScan = { sources: hashes.length, candidates: [...new Set(hashes)], }; const overlappingDataSymbols = dataHeaders.some((section, index) => dataHeaders .slice(0, index) .some( (previous) => previous.offset < section.offset + section.size && section.offset < previous.offset + previous.size, ), ); if (sections.length === 0) return err( new AnalysisUnsupportedTargetError( "inspect_dart_aot", path, `No recognizable Dart snapshot symbols were found: ${inventory.failures.map((failure) => failure.reason).join("; ")}`, ), ); const pool = await projectStringPool(isolateData, signal); const snapshotPartial = inventory.failures.length > 0 || aliasedDataSymbols || overlappingDataSymbols || sections.length < symbolNames.length || dataHeaders.length !== SYMBOL_PAIR_COUNT || dataHeaders.some((section) => !section.header.complete) || hashScan.candidates.length !== 1 || hashScan.sources !== SYMBOL_PAIR_COUNT; const poolPartial = isolateData === null || pool.collectionCapped || pool.packageUriCount > MAX_POOL_URIS || pool.dartUriCount > MAX_POOL_URIS || pool.classLikeTokenCount > MAX_POOL_TOKENS; return ok( createAnalysisExecution( { target: { path, bytes: snapshot.bytes, sha256: snapshot.sha256 }, abi: selected.abi, libapp: { coverage: snapshotPartial ? "partial" : "complete", bytes: imageLength, sha256: imageSha256, snapshot_hash: hashScan.candidates.length === 1 ? hashScan.candidates[0]! : null, snapshot_hash_candidates: [...hashScan.candidates], snapshot_hash_sources: hashScan.sources, sections, }, string_pool: { coverage: poolPartial ? "partial" : "complete", package_uris: pool.packageUris.slice(0, MAX_POOL_URIS), package_uri_count_at_least: pool.packageUriCount, dart_uris: pool.dartUris.slice(0, MAX_POOL_URIS), dart_uri_count_at_least: pool.dartUriCount, class_like_tokens: pool.classLikeTokens.slice(0, MAX_POOL_TOKENS), class_like_token_count_at_least: pool.classLikeTokenCount, printable_run_count: pool.printableRunCount, }, coverage: snapshotPartial || poolPartial ? "partial" : "complete", }, FLUTTER_PROVIDER_IDENTITY, { subject: { path, format: "apk", sha256: snapshot.sha256 }, locations: [{ kind: "artifact-path", path }], limitations: [ "The APK is read through an owned read-only snapshot; concurrent changes to the original cannot mix into this observation", "Snapshot sections are located through the ELF dynamic symbol table; a payload without exported snapshot symbols is refused rather than guessed", "Snapshot hash sources are the headers at the start of the VM and isolate data symbols; complete decoding supports kind 3 (the Dart 2.14 FullAOT layout), a valid hash and a declared length that fits its symbol, including the four magic bytes; other kind values remain observed with partial coverage", "String-pool entries are byte-pattern observations from the isolate data section, not deserialized cluster semantics; typed class and function recovery needs an SDK-specific parser", "class_like_tokens are identifier-shaped runs and include incidental matches", "string-pool counts are lower bounds: collection stops at twice each projection bound, and any value beyond that is counted only through the capped flag", ...inventory.failures.map((failure) => failure.reason), ...(sections.length < symbolNames.length ? [ `Only ${String(sections.length)} of 4 snapshot symbols were present`, ] : []), ...(aliasedDataSymbols ? [ "Two or more snapshot data symbols share one header offset; its hash counts as one source instead of two agreeing ones", ] : []), ...(overlappingDataSymbols && !aliasedDataSymbols ? [ "Snapshot data symbol ranges overlap; independent VM/isolate coverage is not established", ] : []), ...dataHeaders .filter((section) => !section.header.complete) .map( (section) => `${section.name} has an incomplete or invalid snapshot header: kind ${String(section.header.kind)} (AOT app requires 3), declared length ${String(section.header.headerLength)} excludes the four magic bytes, symbol size ${String(section.size)}, valid hash ${String(section.header.hash !== null)}`, ), ...(pool.collectionCapped ? [ "String-pool collection or printable-run capacity was exceeded; counts are lower bounds and overflowed runs are omitted", ] : []), ...(pool.dartUriCount > MAX_POOL_URIS ? [ `dart_uris reports ${String(MAX_POOL_URIS)} of at least ${String(pool.dartUriCount)} distinct URIs`, ] : []), ...(pool.packageUriCount > MAX_POOL_URIS ? [ `package_uris reports ${String(MAX_POOL_URIS)} of at least ${String(pool.packageUriCount)} distinct URIs`, ] : []), ...(pool.classLikeTokenCount > MAX_POOL_TOKENS ? [ `class_like_tokens reports ${String(MAX_POOL_TOKENS)} of at least ${String(pool.classLikeTokenCount)} distinct tokens`, ] : []), ], }, ), ); } } /** * Categorize printable runs from one snapshot data section. Collection stops * admitting new distinct entries once each category reaches its projection * bound times a slack factor, so allocation and sorting stay proportional to * the response rather than to an adversarial payload; counts still reflect * how far scanning proceeded before the collection cap tripped. */ const projectStringPool = async ( section: Buffer | null, signal: AbortSignal | undefined, ): Promise<{ packageUris: string[]; packageUriCount: number; dartUris: string[]; dartUriCount: number; classLikeTokens: string[]; classLikeTokenCount: number; printableRunCount: number; collectionCapped: boolean; }> => { const packages = new Set(); const darts = new Set(); const tokens = new Set(); let runCount = 0; let collectionCapped = false; const COLLECTION_SLACK = 2; /** One printable run never materializes beyond this length. */ const MAX_RUN_BYTES = 1024; const packageBudget = MAX_POOL_URIS * COLLECTION_SLACK; const dartBudget = MAX_POOL_URIS * COLLECTION_SLACK; const tokenBudget = MAX_POOL_TOKENS * COLLECTION_SLACK; if (section !== null) { let run = ""; const consider = (text: string): void => { if (text.length < 5) return; runCount += 1; if (text.startsWith("package:")) { if (!packages.has(text) && packages.size >= packageBudget) collectionCapped = true; else packages.add(text); } else if (text.startsWith("dart:")) { if (!darts.has(text) && darts.size >= dartBudget) collectionCapped = true; else darts.add(text); } else if ( text.length <= 128 && text.length >= 5 && IDENTIFIER_PATTERN.test(text) ) { if (!tokens.has(text) && tokens.size >= tokenBudget) collectionCapped = true; else tokens.add(text); } }; let overflowed = false; for (let start = 0; start < section.length; start += 64 * 1024) { await yieldToEventLoop(); signal?.throwIfAborted(); for (const byte of section.subarray(start, start + 64 * 1024)) { if (byte >= 0x20 && byte < 0x7f) { // A single unbounded run (an adversarial NUL-free section) never // materializes: growth stops at the cap and the entire run is // discarded at its delimiter, since a truncated prefix would be // fabricated evidence rather than an observation. if (run.length < MAX_RUN_BYTES) run += String.fromCharCode(byte); else { overflowed = true; collectionCapped = true; } } else { if (!overflowed) consider(run); overflowed = false; run = ""; } } } if (!overflowed) consider(run); } signal?.throwIfAborted(); return { packageUris: [...packages].sort(), packageUriCount: packages.size, dartUris: [...darts].sort(), dartUriCount: darts.size, classLikeTokens: [...tokens].sort(), classLikeTokenCount: tokens.size, printableRunCount: runCount, collectionCapped, }; };