From 99d2d7ad22bcbd780919760008aba6a88e7d1077 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:18:43 +0800 Subject: [PATCH] fix(parsers): retain independent facts and boundary identity Share bounded ELF section-name decoding across native and note readers; retain read-only ranges and coverage reasons when names are unknown. Preserve ELF symbol-version bytes, dyld-bound method uncertainty, partial gRPC-Web trailers, and per-parent model identity checks. Compare GPU signatures as bytes and require a PTX module header. Share selected-path errno classification across EVM and BEAM. Validation: 397 shared-parser tests passed (one existing skip), 5 MCP schema tests passed, 14 EVM error-mapping tests passed, and 23 protobuf CLI/MCP matrix cases passed across focused runs. Build, typecheck/lint, docs, formatting, and 2134-file discovery passed. Full EVM provider workflow remains locally unverified on Darwin arm64; format controls are synthetic. Fixes #6738 Fixes #6737 Fixes #6745 Fixes #6740 Fixes #6749 Fixes #6747 Fixes #6739 --- docs/binary-diagnostics.md | 7 +- docs/evm-bytecode.md | 6 +- docs/go-binaries.md | 3 +- docs/gpu-code-objects.md | 10 +- docs/il2cpp-metadata.md | 4 +- docs/rust-binaries.md | 6 + docs/web-network-captures.md | 4 + src/artifacts/ArtifactFileOperation.ts | 26 ++++ src/browser/history/protobuf/ProtobufBody.ts | 9 +- .../protobuf/ProtobufBodyTrailer.test.ts | 67 ++++++++- .../history/protobuf/ProtobufTrailers.ts | 135 +++++++++++++----- src/domain/analysisView/modelArtifactView.ts | 12 +- src/domain/native/nativeExports.ts | 4 + src/erlang/BeamProvider.ts | 10 +- src/evm/EvmoleFailures.test.ts | 40 ++++++ src/evm/EvmoleFailures.ts | 10 +- src/go/GoBinaryImage.ts | 17 ++- src/go/GoBinaryProvider.ts | 10 +- src/gpu/ClangOffloadBundle.ts | 3 + src/gpu/GpuInventory.ts | 7 +- src/gpu/PtxKernels.ts | 21 +-- src/il2cpp/Il2cppBinaryImage.ts | 56 +++++++- src/native/elf/ElfNoteParser.ts | 61 ++++---- src/native/exports/NativeExportParser.test.ts | 111 +++++++++++++- src/native/exports/NativeExportParser.ts | 60 +++++--- src/native/metadata/BinaryContainer.ts | 7 +- src/native/metadata/ElfImage.ts | 7 +- src/native/metadata/ElfSectionNameTable.ts | 73 ++++++++++ src/native/metadata/NativeSectionDirectory.ts | 78 +++++++--- src/rust/RustBinaryImage.ts | 4 + src/rust/RustBinaryProvider.ts | 19 ++- .../nativeExportsVersionRequirements.test.ts | 41 ++++++ tests/acceptance/il2cppMetadataCli.test.ts | 47 ++++++ tests/acceptance/rustBinaryCli.test.ts | 52 +++++++ .../acceptance/tools/protobufCapture.test.ts | 17 ++- .../evm/evmoleInterfaceProvider.test.ts | 37 ++++- .../boundary/filesystem/nativeExports.test.ts | 29 +++- tests/boundary/go/goBinaryProvider.test.ts | 22 +++ tests/boundary/gpu/codeObjects.test.ts | 44 ++++++ .../boundary/il2cpp/il2cppBinaryFile.test.ts | 17 +++ .../boundary/mcp/binaryDiagnosticsMcp.test.ts | 23 ++- .../model/modelArtifactViewIdentity.test.ts | 78 ++++++++++ tests/boundary/rust/rustBinaryImage.test.ts | 38 +++++ tests/fixtures/il2cpp/binary.ts | 21 +++ tests/fixtures/rust/image.ts | 32 +++++ 45 files changed, 1209 insertions(+), 176 deletions(-) create mode 100644 src/native/metadata/ElfSectionNameTable.ts create mode 100644 tests/boundary/model/modelArtifactViewIdentity.test.ts diff --git a/docs/binary-diagnostics.md b/docs/binary-diagnostics.md index 6b84224255..c4779846e1 100644 --- a/docs/binary-diagnostics.md +++ b/docs/binary-diagnostics.md @@ -341,7 +341,12 @@ count and SHA-256. report null for the newly added encoding fields. `version` is null for an unversioned symbol. A versioned symbol reports its GNU index, a name resolved from `.gnu.version_d` or - `.gnu.version_r`, and the `hidden` flag from `.gnu.version`. `"unknown"` + `.gnu.version_r`, the corresponding `name_bytes_base64` and + `name_utf8_decodable` facts, and the `hidden` flag from `.gnu.version`. + Version names use the same UTF-8-when-valid, otherwise byte-preserving + Latin-1 display mapping as symbol names; ELF does not declare either + string's text encoding. Older retained results report null for the added + version-name encoding fields. `"unknown"` means the symbol-version table could not be read. A hidden version is not the default version that a by-name `dlsym` lookup selects. Definition names take precedence when both tables name the same index. diff --git a/docs/evm-bytecode.md b/docs/evm-bytecode.md index fa61b53db8..6ee6029cf3 100644 --- a/docs/evm-bytecode.md +++ b/docs/evm-bytecode.md @@ -19,8 +19,10 @@ rea inspect-evm-interface ./runtime.bin raw --json Initial real verification covers flat legacy bytecode on Linux x64 and unchanged bundled EVMole 0.9.3. EOF-style EF00 containers return `unsupported_target`, -with the selected path and format reason. Provider/configuration failures retain -their distinct unavailable-provider or capability classification. +with the selected path and format reason. Selected-path resolution errors +`ENOENT`, `ENOTDIR`, `ELOOP`, and `ENAMETOOLONG` are `invalid_request` path +issues. Permission, resource, cancellation, provider, and cleanup failures retain +their distinct classifications. The host must already provide util-linux `prlimit`, normally `/usr/bin/prlimit`; `REA_EVM_PRLIMIT_COMMAND` can select another absolute executable. REA installs no system tools and changes no user configuration. [Upstream provenance and diff --git a/docs/go-binaries.md b/docs/go-binaries.md index fd2014a816..58b2679fd2 100644 --- a/docs/go-binaries.md +++ b/docs/go-binaries.md @@ -130,7 +130,8 @@ For ELF images without a named build-info section, discovery checks each nonempty writable, non-executable load segment in program-header order. This includes sectionless PIE images with metadata in a later data segment. Named sections retain priority, and alignment, file mapping and record validation -remain required. +remain required. When ELF section names are unreadable, limitations report that +coverage gap; the fallback search policy is unchanged. A named ELF build-info section selects the record's discovery range. For inline encoding, the following version and module strings may continue past that diff --git a/docs/gpu-code-objects.md b/docs/gpu-code-objects.md index 080a71af71..2ce43b0372 100644 --- a/docs/gpu-code-objects.md +++ b/docs/gpu-code-objects.md @@ -36,9 +36,11 @@ HIP bundle recognition accepts both the legacy `amdgcn` triple and LLVM's `amdgpu` subarchitecture triples, including declared generic processors such as `gfx11-generic` and `gfx9-4-generic`. CUDA's legacy fatbinary compression flag is retained as an unsupported codec with its encoded and decoded extents; -compressed bytes are not treated as PTX text. CCOB v1 has no stored total size, -so a following legacy bundle is located by the next `CCOB` signature and -zero-byte alignment padding remains part of the first container extent. +compressed bytes are not treated as PTX text. CCOB is recognized by the exact +four signature bytes `CCOB`; high-bit-corrupted bytes are not decoded as ASCII +and accepted as a bundle. CCOB v1 has no stored total size, so a following +legacy bundle is located by the next `CCOB` signature and zero-byte alignment +padding remains part of the first container extent. `cuda_target_header` retains the OS ABI, normalized raw `e_flags` and exact header location. CUDA OS ABI `0x33` and the observed `0x41` flags layout use @@ -191,3 +193,5 @@ failures preserve earlier successfully parsed declarations and the original failure reason. Standalone PTX recognition tries a small header first, then continues within the existing 16 MiB text budget when leading comments or whitespace obscure its directives; admitted full-file probe bytes are reused. +After those comments and whitespace, `.version` must be the first PTX module +token; later PTX-looking text does not identify an arbitrary binary as PTX. diff --git a/docs/il2cpp-metadata.md b/docs/il2cpp-metadata.md index ca59c01090..017b0e40db 100644 --- a/docs/il2cpp-metadata.md +++ b/docs/il2cpp-metadata.md @@ -248,7 +248,9 @@ The result adds: `rva` (address minus `image_base`), both hexadecimal strings. `state` is `code` when the module slot holds an unambiguously mapped executable address, `no-body` when an observed slot is zero, as for abstract, extern and most generic methods, and `unknown` when no type - range in any image holds the method or a symbol relocation leaves its slot unresolved. + range in any image holds the method, a symbol relocation leaves its slot + unresolved, or dyld will bind a Mach-O slot. The raw word in a dyld-bound + slot is not treated as the resolved address. `address` and `rva` are `null` unless `state` is `code`. The summary counts each state in `counts.method_address_states`. Ghidra seeds are written only for `code` rows. diff --git a/docs/rust-binaries.md b/docs/rust-binaries.md index 7e16a60bfe..81fac3bda1 100644 --- a/docs/rust-binaries.md +++ b/docs/rust-binaries.md @@ -82,6 +82,12 @@ runtime dependency coverage. A missing section remains unknown rather than an empty dependency set. Retained views describe the captured artifact digest; they do not assert that the current file or running application is unchanged. +ELF section names are resolved through the section-header string table. +Zlib-compressed name tables are decoded within the native structural budget. +If a name table uses unsupported compression, the result reports that +limitation and leaves `.dep-v0` presence unknown rather than reporting the +section absent. + The inline Evidence result contains the artifact path, SHA-256, byte length, container format, architecture, word size, and byte order. When a `.dep-v0` section is present, `auditable_metadata` contains: diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 4cd975ff99..886d2d2c24 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -137,6 +137,10 @@ chunks. Its offsets refer to **base64-decoded body bytes**; other offsets refer to retained body bytes. Every field has a body-scoped `field_id` derived from its byte offset, a `frame_index`, and a `parent_field_id` for schema-driven nested messages or groups. Repeated field numbers remain separate occurrences. +Trailer blocks are parsed as HTTP field bytes. Valid UTF-8 values are retained; +when a field contains opaque non-UTF-8 bytes, that field is omitted and the +trailer frame is partial, while other valid trailer fields remain available. +The retained body and frame byte range preserve the original opaque value. Without a schema, varints and fixed-width values retain exact unsigned decimal representations. Length-delimited UTF-8, nested-message and packed readings diff --git a/src/artifacts/ArtifactFileOperation.ts b/src/artifacts/ArtifactFileOperation.ts index 1dda61099c..9d78f79fc4 100644 --- a/src/artifacts/ArtifactFileOperation.ts +++ b/src/artifacts/ArtifactFileOperation.ts @@ -90,6 +90,32 @@ export class ArtifactFileCleanupFailure< } } +/** Errnos raised while the caller-selected artifact path is being resolved. */ +export type ArtifactSelectedPathErrno = + | "ENOENT" + | "ENOTDIR" + | "ELOOP" + | "ENAMETOOLONG"; + +/** Return only known path-resolution errnos; permissions and I/O remain distinct. */ +export const artifactSelectedPathErrno = ( + cause: unknown, +): ArtifactSelectedPathErrno | undefined => { + if (!(cause instanceof Error) || !("code" in cause)) return undefined; + switch (String(cause.code)) { + case "ENOENT": + return "ENOENT"; + case "ENOTDIR": + return "ENOTDIR"; + case "ELOOP": + return "ELOOP"; + case "ENAMETOOLONG": + return "ENAMETOOLONG"; + default: + return undefined; + } +}; + /** Normalize the physical primary failure without replacing its exact cleanup owner. */ export const mapArtifactFileFailure = ( cause: unknown, diff --git a/src/browser/history/protobuf/ProtobufBody.ts b/src/browser/history/protobuf/ProtobufBody.ts index 7cf5674067..e75f0dd347 100644 --- a/src/browser/history/protobuf/ProtobufBody.ts +++ b/src/browser/history/protobuf/ProtobufBody.ts @@ -161,11 +161,10 @@ export const decodeProtobufBody = async ( ); const facts = parsed.ok ? parsed.value : parsed.error; frame.examined_bytes = facts.examined_bytes; - if (parsed.ok || parsed.error.kind !== "invalid-utf8") - frame.trailers = facts.trailers.map(({ name, value }) => ({ - name: redact(name), - value: redact(value), - })); + frame.trailers = facts.trailers.map(({ name, value }) => ({ + name: redact(name), + value: redact(value), + })); if (!parsed.ok) { frame.status = "partial"; frame.error = { diff --git a/src/browser/history/protobuf/ProtobufBodyTrailer.test.ts b/src/browser/history/protobuf/ProtobufBodyTrailer.test.ts index 71dbb30306..ec58d042a9 100644 --- a/src/browser/history/protobuf/ProtobufBodyTrailer.test.ts +++ b/src/browser/history/protobuf/ProtobufBodyTrailer.test.ts @@ -9,11 +9,19 @@ const source = { }; const same = (value: string) => value; const trailerFrame = (text: string) => { - const payload = Buffer.from(text, "utf8"); + return trailerFrameBytes(Buffer.from(text, "utf8")); +}; +const trailerFrameBytes = (payload: Uint8Array) => { const frame = Buffer.alloc(5 + payload.length); frame[0] = 128; frame.writeUInt32BE(payload.length, 1); - payload.copy(frame, 5); + frame.set(payload, 5); + return frame; +}; +const dataFrame = (payload: Uint8Array) => { + const frame = Buffer.alloc(5 + payload.length); + frame.writeUInt32BE(payload.length, 1); + frame.set(payload, 5); return frame; }; @@ -63,6 +71,55 @@ it("retains allowed whitespace, visible text, and valid UTF-8 obs-text", async ( expect(result.examined_bytes).toBe(5 + Buffer.byteLength(text)); }); +it.each(["grpc-web", "grpc-web-text"] as const)( + "keeps valid trailer fields around an opaque non-UTF-8 value in %s framing", + async (framing) => { + const trailer = Buffer.concat([ + Buffer.from("grpc-status: 0\r\nx-note: caf", "ascii"), + Buffer.from([0xe9]), + Buffer.from("\r\nx-after: kept\r\n", "ascii"), + ]); + const binary = Buffer.concat([ + dataFrame(Buffer.from([0x08, 0x01])), + trailerFrameBytes(trailer), + ]); + const input = + framing === "grpc-web-text" + ? Buffer.from(binary.toString("base64"), "ascii") + : binary; + const result = await decodeProtobufBody( + input, + framing, + source, + same, + new ProtobufBudget(), + ); + + expect(result.status).toBe("partial"); + expect(result.fields).toMatchObject([ + { number: 1, wire_type: "varint", value: "1" }, + ]); + expect(result.frames[1]).toMatchObject({ + kind: "trailer", + offset: binary.length - trailer.length - 5, + payload_offset: binary.length - trailer.length, + bytes: trailer.length, + status: "partial", + examined_bytes: trailer.length, + error: { + reason: + "gRPC-web trailer field contains opaque non-UTF-8 bytes; that field was omitted", + }, + trailers: [ + { name: "grpc-status", value: "0" }, + { name: "x-after", value: "kept" }, + ], + }); + expect(result.examined_bytes).toBe(binary.length); + expect(binary.subarray(-trailer.length)).toEqual(trailer); + }, +); + it.each(["Grpc-Status", "GRPC-STATUS"])( "retains a valid prefix when the trailer name %s violates gRPC-Web casing", async (name) => { @@ -82,7 +139,7 @@ it.each(["Grpc-Status", "GRPC-STATUS"])( }, ); -it("leaves undecodable trailer fields unknown and preserves unexpected failures", async () => { +it("omits a trailer field with a non-ASCII name and preserves unexpected failures", async () => { const input = trailerFrame("grpc-status: 0\r\n"); input[5] = 255; const malformed = await decodeProtobufBody( @@ -93,8 +150,8 @@ it("leaves undecodable trailer fields unknown and preserves unexpected failures" new ProtobufBudget(), ); expect(malformed.status).toBe("partial"); - expect(malformed.frames[0]?.examined_bytes).toBe(0); - expect(malformed.frames[0]?.trailers).toBeUndefined(); + expect(malformed.frames[0]?.examined_bytes).toBe("grpc-status: 0\r\n".length); + expect(malformed.frames[0]?.trailers).toEqual([]); const failure = { kind: "controlled-redaction-failure" }; await expect( diff --git a/src/browser/history/protobuf/ProtobufTrailers.ts b/src/browser/history/protobuf/ProtobufTrailers.ts index 040923202d..1f382f0b27 100644 --- a/src/browser/history/protobuf/ProtobufTrailers.ts +++ b/src/browser/history/protobuf/ProtobufTrailers.ts @@ -7,7 +7,7 @@ interface TrailerFacts { } type TrailerFailure = TrailerFacts & { - readonly kind: "invalid-utf8" | "invalid-name" | "invalid-value"; + readonly kind: "invalid-utf8-value" | "invalid-name" | "invalid-value"; readonly reason: string; }; @@ -16,45 +16,116 @@ export const parseGrpcWebTrailers = ( bytes: Uint8Array, budget: ProtobufBudget, ): Result => { - let text: string; - try { - text = new TextDecoder("utf-8", { - fatal: true, - ignoreBOM: true, - }).decode(bytes); - } catch (cause) { - if (!(cause instanceof TypeError)) throw cause; - return err({ - kind: "invalid-utf8", - reason: "gRPC-web trailer header block is not valid UTF-8", - trailers: [], - examined_bytes: 0, - }); - } - // The whole UTF-8 block has been examined even when field validation stops. - const facts: TrailerFacts = { trailers: [], examined_bytes: bytes.length }; - for (const line of text.split("\r\n")) { - if (line === "") continue; - const colon = line.indexOf(":"); - // Unlike ordinary HTTP/1 headers, in-body gRPC-Web names must be lower-case. - if (colon <= 0 || !/^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(line.slice(0, colon))) + let decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + const trailers: TrailerFacts["trailers"] = []; + let examinedBytes = 0; + const facts = (): TrailerFacts => ({ + trailers, + examined_bytes: examinedBytes, + }); + let opaqueValueFailure: TrailerFailure | undefined; + for (let start = 0; start <= bytes.length;) { + let end = start; + while ( + end + 1 < bytes.length && + !(bytes[end] === 13 && bytes[end + 1] === 10) + ) + end += 1; + const hasSeparator = end + 1 < bytes.length; + const lineEnd = hasSeparator ? end : bytes.length; + const lineExaminedBytes = hasSeparator ? end + 2 : lineEnd; + if (lineEnd === start) { + if (!hasSeparator) break; + examinedBytes = lineExaminedBytes; + start = end + 2; + continue; + } + let colon = start; + while (colon < lineEnd && bytes[colon] !== 58) colon += 1; + if (colon === start || colon === lineEnd) return err({ - ...facts, + ...facts(), + examined_bytes: lineExaminedBytes, kind: "invalid-name", reason: "gRPC-web trailer header names must be lower-case HTTP field names", }); - const value = line.slice(colon + 1).replace(/^[ \t]+/, ""); - // RFC 9110 permits HTAB but excludes other C0 controls and DEL. - if (/[\u0000-\u0008\u000a-\u001f\u007f]/u.test(value)) + let name = ""; + let validName = true; + for (let index = start; index < colon; index += 1) { + const byte = bytes[index]!; + if (!isTrailerNameByte(byte)) { + validName = false; + break; + } + name += String.fromCharCode(byte); + } + // Unlike ordinary HTTP/1 headers, in-body gRPC-Web names must be lower-case. + if (!validName) return err({ - ...facts, - kind: "invalid-value", + ...facts(), + examined_bytes: lineExaminedBytes, + kind: "invalid-name", reason: - "gRPC-web trailer field value contains a forbidden control character", + "gRPC-web trailer header names must be lower-case HTTP field names", }); + let valueStart = colon + 1; + while (valueStart < lineEnd && [9, 32].includes(bytes[valueStart]!)) + valueStart += 1; + // RFC 9110 permits HTAB but excludes other C0 controls and DEL. + for (let index = valueStart; index < lineEnd; index += 1) { + const byte = bytes[index]!; + if (byte <= 8 || (byte >= 10 && byte <= 31) || byte === 127) + return err({ + ...facts(), + examined_bytes: lineExaminedBytes, + kind: "invalid-value", + reason: + "gRPC-web trailer field value contains a forbidden control character", + }); + } + let value: string; + try { + value = decoder.decode(bytes.subarray(valueStart, lineEnd)); + } catch (cause) { + if (!(cause instanceof TypeError)) throw cause; + decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + opaqueValueFailure ??= { + ...facts(), + kind: "invalid-utf8-value", + reason: + "gRPC-web trailer field contains opaque non-UTF-8 bytes; that field was omitted", + }; + examinedBytes = lineExaminedBytes; + if (!hasSeparator) break; + start = end + 2; + continue; + } budget.charge(512); - facts.trailers.push({ name: line.slice(0, colon), value }); + trailers.push({ name, value }); + examinedBytes = lineExaminedBytes; + if (!hasSeparator) break; + start = end + 2; } - return ok(facts); + if (opaqueValueFailure !== undefined) + return err({ + ...opaqueValueFailure, + trailers, + examined_bytes: bytes.length, + }); + return ok({ trailers, examined_bytes: bytes.length }); }; + +const isTrailerNameByte = (byte: number): boolean => + (byte >= 97 && byte <= 122) || + (byte >= 48 && byte <= 57) || + byte === 33 || + (byte >= 35 && byte <= 39) || + byte === 42 || + byte === 43 || + (byte >= 45 && byte <= 46) || + byte === 94 || + byte === 95 || + byte === 96 || + byte === 124 || + byte === 126; diff --git a/src/domain/analysisView/modelArtifactView.ts b/src/domain/analysisView/modelArtifactView.ts index 4fb33bba3f..c072779f94 100644 --- a/src/domain/analysisView/modelArtifactView.ts +++ b/src/domain/analysisView/modelArtifactView.ts @@ -143,10 +143,13 @@ const read = (parent: AnalysisViewParent): Result => { typeof key === "object" && isImmutableJsonSnapshot(key); const cached = reusable ? checked.get(key) : undefined; - if (cached !== undefined) return ok(cached); - const parsed = parseAnalysisViewResult(modelArtifactSchema, parent); - if (!parsed.ok) return parsed; - const report = reusable ? (key as ModelArtifact) : parsed.value; + let report: ModelArtifact; + if (cached !== undefined) report = cached.report; + else { + const parsed = parseAnalysisViewResult(modelArtifactSchema, parent); + if (!parsed.ok) return parsed; + report = reusable ? (key as ModelArtifact) : parsed.value; + } if ( parent.subject != null && (parent.subject.local_path !== report.artifact.path || @@ -162,6 +165,7 @@ const read = (parent: AnalysisViewParent): Result => { }, ]), ); + if (cached !== undefined) return ok(cached); const summary = summarizeModel(report); const { artifact: _artifact, diff --git a/src/domain/native/nativeExports.ts b/src/domain/native/nativeExports.ts index 05ee4617b6..3d03f79996 100644 --- a/src/domain/native/nativeExports.ts +++ b/src/domain/native/nativeExports.ts @@ -64,6 +64,10 @@ const elfExportSchema = z.strictObject({ z.strictObject({ index: z.number().int().min(2).max(0x7fff), name: z.string().min(1).nullable(), + /** Exact non-ASCII version-name bytes; ELF strings do not declare encoding. */ + name_bytes_base64: z.string().nullable().default(null), + /** Null means older Evidence did not retain version-name encoding facts. */ + name_utf8_decodable: z.boolean().nullable().default(null), hidden: z.boolean(), }), ]), diff --git a/src/erlang/BeamProvider.ts b/src/erlang/BeamProvider.ts index 7c6a2ef3e5..75df250af7 100644 --- a/src/erlang/BeamProvider.ts +++ b/src/erlang/BeamProvider.ts @@ -2,6 +2,7 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { readStableArtifact } from "../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFailure, + artifactSelectedPathErrno, bindArtifactFileCleanup, artifactFileCancellationCause, } from "../artifacts/ArtifactFileOperation.js"; @@ -163,16 +164,13 @@ const beamFailurePrimary = (cause: unknown, input: InspectErlangBeamInput) => { return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { cause, }); - if ( - ["ENOENT", "ENOTDIR", "ELOOP", "ENAMETOOLONG"].includes( - String(cause.code), - ) - ) + const pathErrno = artifactSelectedPathErrno(cause); + if (pathErrno !== undefined) return new AnalysisInputError(OPERATION, { cause }, [ { path: ["path"], reason: "invalid_value", - message: `Selected BEAM file could not be read (${String(cause.code)}): ${input.path}.`, + message: `Selected BEAM file could not be read (${pathErrno}): ${input.path}.`, }, ]); } diff --git a/src/evm/EvmoleFailures.test.ts b/src/evm/EvmoleFailures.test.ts index f4e566c0e1..ffdaf88d49 100644 --- a/src/evm/EvmoleFailures.test.ts +++ b/src/evm/EvmoleFailures.test.ts @@ -1,3 +1,6 @@ +import { readFile, symlink } from "node:fs/promises"; +import { join } from "node:path"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { expect, it } from "vitest"; import { evmInterfaceFailure } from "./EvmoleFailures.js"; import { OwnedCommandFailure } from "../process/OwnedCommand.js"; @@ -187,3 +190,40 @@ it.each([ expect(result.cleanupResources).toEqual(["exact-owner"]); } }); + +it.skipIf(process.platform === "win32")( + "maps real selected-path failures before the EVM worker starts", + async () => { + const root = await createTestTempDirectory("rea-evm-path-mapping-"); + await symlink("loop", join(root, "loop")); + for (const [code, path] of [ + ["ELOOP", join(root, "loop", "carrier.bin")], + ["ENAMETOOLONG", join(root, "x".repeat(256))], + ] as const) { + let source: unknown; + try { + await readFile(path); + throw new Error("Expected selected-path read failure"); + } catch (cause) { + source = cause; + } + const failure = evmInterfaceFailure(source, "artifact-read", path); + expect(failure.cause).toBe(source); + expect(projectAnalysisError(failure)).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["path"], + reason: "invalid_value", + message: expect.stringContaining(code), + }, + ], + }, + }); + expect(evmInterfaceFailure(source, "worker", path)._tag).toBe( + "ProviderAdapterError", + ); + } + }, +); diff --git a/src/evm/EvmoleFailures.ts b/src/evm/EvmoleFailures.ts index 15c3f4cdfa..d258c85f5e 100644 --- a/src/evm/EvmoleFailures.ts +++ b/src/evm/EvmoleFailures.ts @@ -1,6 +1,9 @@ import type { EvmWorkerLimits } from "./EvmWorkerLimits.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; -import { artifactFileCancellationCause } from "../artifacts/ArtifactFileOperation.js"; +import { + artifactFileCancellationCause, + artifactSelectedPathErrno, +} from "../artifacts/ArtifactFileOperation.js"; import { AnalysisError, type AnalysisCapturedOutput, @@ -212,7 +215,8 @@ export const evmInterfaceFailure = ( cause.code, { cause, ...cleanupOptions }, ); - if (cause.code === "ENOENT" || cause.code === "ENOTDIR") + const pathErrno = artifactSelectedPathErrno(cause); + if (pathErrno !== undefined) return new AnalysisInputError( "inspect_evm_interface", { cause, ...cleanupOptions }, @@ -220,7 +224,7 @@ export const evmInterfaceFailure = ( { path: ["path"], reason: "invalid_value", - message: `Selected bytecode carrier could not be read (${String(cause.code)}): ${path}.`, + message: `Selected bytecode carrier could not be read (${pathErrno}): ${path}.`, }, ], ); diff --git a/src/go/GoBinaryImage.ts b/src/go/GoBinaryImage.ts index f5df25c4ac..2d34e983ba 100644 --- a/src/go/GoBinaryImage.ts +++ b/src/go/GoBinaryImage.ts @@ -41,6 +41,8 @@ export interface GoBinaryImage { readonly bits: 32 | 64; readonly byte_order: "little" | "big"; readonly build_info: GoBuildInfo | null; + /** Native section-name coverage, retained for discovery limitations. */ + readonly section_names_limitation?: string; } /** Inspect bounded executable bytes using native mappings, without loading or executing code. */ @@ -50,6 +52,7 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => { let selected: { search: FileMapping; header: number } | null = null; const searched = new Set(); let remainingSearchBytes = MAX_HEADER_SEARCH_BYTES; + const sectionNamesLimitation = container.section_names_limitation; for (const search of goBuildInfoSearch(bytes, container)) { // Identical file spans and alignment inspect the same candidate bytes. // Partially overlapping spans still consume a cumulative work budget. @@ -70,7 +73,16 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => { } } if (selected === null) - return { format, architecture, bits, byte_order, build_info: null }; + return { + format, + architecture, + bits, + byte_order, + build_info: null, + ...(sectionNamesLimitation === undefined + ? {} + : { section_names_limitation: sectionNamesLimitation }), + }; const { search, header } = selected; const verifyMapping = (size: number): void => { const address = search.address + BigInt(header - search.offset); @@ -175,6 +187,9 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => { version_location: version.location, module_location: module.location, }, + ...(sectionNamesLimitation === undefined + ? {} + : { section_names_limitation: sectionNamesLimitation }), }; }; diff --git a/src/go/GoBinaryProvider.ts b/src/go/GoBinaryProvider.ts index c9d96be25b..0ad91d0e89 100644 --- a/src/go/GoBinaryProvider.ts +++ b/src/go/GoBinaryProvider.ts @@ -160,6 +160,7 @@ const outputLimit = (path: string) => const limitationsFor = ( info: { go_version: string | null; module_text: string | null } | null, module: GoModuleMetadata | null, + sectionNamesLimitation: string | undefined, ): string[] => [ "Embedded build metadata is observed file content, not proof of compiler identity, dependency authenticity or runtime behavior.", "Only Go build information is inspected; function names, source mappings, type metadata and decompiled code are not recovered.", @@ -174,6 +175,7 @@ const limitationsFor = ( "No recognized Go build-info record was found in the inspected image; absent metadata does not establish that the binary is not Go.", ] : []), + ...(sectionNamesLimitation === undefined ? [] : [sectionNamesLimitation]), ...(module !== null && module.main === null ? [ "Dependency coverage is unknown because no main-module record was decoded from the embedded module information, as in GOPATH-mode builds. Decoded dependency records remain visible; an empty list does not establish the absence of dependencies.", @@ -253,6 +255,10 @@ export class GoBinaryProvider implements GoBinaryPort { input.architecture, ); const image = readGoBinaryImage(selected.bytes); + const { + section_names_limitation: sectionNamesLimitation, + ...publicImage + } = image; const info = inFile(image.build_info, selected.fileOffset); if ( info !== null && @@ -273,7 +279,7 @@ export class GoBinaryProvider implements GoBinaryPort { ) : parseGoModuleText(info.module_text); const report = { - ...image, + ...publicImage, artifact: { path: input.path, sha256: snapshot.sha256, @@ -283,7 +289,7 @@ export class GoBinaryProvider implements GoBinaryPort { ? {} : { selected_slice: selected.selection }), build_info: info === null ? null : { ...info, module }, - limitations: limitationsFor(info, module), + limitations: limitationsFor(info, module, sectionNamesLimitation), }; if (Buffer.byteLength(JSON.stringify(report), "utf8") > OUTPUT_BYTES) throw outputLimit(input.path); diff --git a/src/gpu/ClangOffloadBundle.ts b/src/gpu/ClangOffloadBundle.ts index fc2615f99d..0a42368f5b 100644 --- a/src/gpu/ClangOffloadBundle.ts +++ b/src/gpu/ClangOffloadBundle.ts @@ -15,6 +15,7 @@ import { import { gpuObject } from "./GpuObject.js"; import { isAmdGpuBundleId, readAmdGpuBundleTarget } from "./AmdGpuTarget.js"; export const CLANG_BUNDLE_MAGIC = Buffer.from("__CLANG_OFFLOAD_BUNDLE__"); +export const CCOB_MAGIC = Buffer.from("CCOB"); export const readClangBundleDirectory = async ( source: GpuSource, base: number, @@ -85,6 +86,8 @@ export const readClangBundleDirectory = async ( }; export const readCcobHeader = (bytes: Buffer, remaining: number) => { if (bytes.length < 20) rejectGpu("CCOB header is truncated"); + if (!bytes.subarray(0, CCOB_MAGIC.length).equals(CCOB_MAGIC)) + rejectGpu("Invalid CCOB magic"); const version = bytes.readUInt16LE(4), method = bytes.readUInt16LE(6); let width: number, total: number, decoded: number, hash: bigint; diff --git a/src/gpu/GpuInventory.ts b/src/gpu/GpuInventory.ts index 5b8441437b..9ac8a60c8e 100644 --- a/src/gpu/GpuInventory.ts +++ b/src/gpu/GpuInventory.ts @@ -1,4 +1,5 @@ import { + CCOB_MAGIC, CLANG_BUNDLE_MAGIC, readLegacyCcobTotal, readClangBundleDirectory, @@ -146,13 +147,13 @@ export class GpuInventory { await this.#hip(source, depth); return "hip-bundle"; } - if (prefix.subarray(0, 4).toString("ascii") === "CCOB") { + if (prefix.subarray(0, CCOB_MAGIC.length).equals(CCOB_MAGIC)) { await this.#hip(source, depth); return "hip-bundle"; } if ( prefix.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) || - prefix.subarray(0, 2).toString("ascii") === "MZ" + (prefix[0] === 0x4d && prefix[1] === 0x5a) ) { const elf = prefix[0] === 0x7f; const directory = await readGpuNativeDirectory( @@ -543,7 +544,7 @@ export class GpuInventory { base = await nextGpuContainerOffset(source, directory.end); continue; } - if (prefix.subarray(0, 4).toString("ascii") !== "CCOB") + if (!prefix.subarray(0, CCOB_MAGIC.length).equals(CCOB_MAGIC)) rejectGpu("Unexpected bytes between Clang offload bundles"); const legacyTotal = prefix.length >= 6 && prefix.readUInt16LE(4) === 1 diff --git a/src/gpu/PtxKernels.ts b/src/gpu/PtxKernels.ts index 89b4846adf..6a0363437f 100644 --- a/src/gpu/PtxKernels.ts +++ b/src/gpu/PtxKernels.ts @@ -535,8 +535,7 @@ export const ptxTargetIdentity = (target: string): string => /** Resolve all module-scope targets through the highest numbered architecture. */ export const readPtxTarget = (text: string): PtxTargetFacts => { const stream = tokens(text); - let version = false, - depth = 0, + let depth = 0, target: string | null = null, targetIdentity: string | null = null, targetArchitecture = -1, @@ -544,6 +543,15 @@ export const readPtxTarget = (text: string): PtxTargetFacts => { unsupported = false, malformed = false; try { + const first = stream.next(); + if (first.done || first.value.value !== ".version") + return { recognized: false, target: null, limitation: null }; + const declaredVersion = stream.next(); + if ( + declaredVersion.done || + ptxVersion(declaredVersion.value.value) === null + ) + return { recognized: false, target: null, limitation: null }; for (const token of stream) { if (token.value === "{") { depth++; @@ -554,12 +562,7 @@ export const readPtxTarget = (text: string): PtxTargetFacts => { continue; } if (depth !== 0) continue; - if (token.value === ".version") { - const value = stream.next(); - version = !value.done && ptxVersion(value.value.value) !== null; - continue; - } - if (token.value !== ".target" || !version) continue; + if (token.value !== ".target") continue; const value = stream.next(); if (value.done) { unsupported = true; @@ -593,7 +596,7 @@ export const readPtxTarget = (text: string): PtxTargetFacts => { if (!(cause instanceof BinaryFormatFailure)) throw cause; malformed = true; } - const recognized = version && target !== null; + const recognized = target !== null; const limitation = !recognized ? null : unsupported diff --git a/src/il2cpp/Il2cppBinaryImage.ts b/src/il2cpp/Il2cppBinaryImage.ts index 5e6630c70c..5ce825f31a 100644 --- a/src/il2cpp/Il2cppBinaryImage.ts +++ b/src/il2cpp/Il2cppBinaryImage.ts @@ -10,6 +10,14 @@ import { type BinaryContainer, } from "../native/metadata/BinaryContainer.js"; import { readBinaryContainer } from "../native/metadata/readBinaryContainer.js"; +import { + parsePointerFixups, + type PointerFixups, +} from "../native/AppleMachoFixups.js"; +import { + parseMachoLayout, + UnsupportedMachoMetadataFormatError, +} from "../native/AppleMachoSelection.js"; import { applyElfRelativeRelocationsSteps } from "./ElfRelocations.js"; import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js"; const MACHO_CPU = new Map([ @@ -68,6 +76,8 @@ export interface NativeImage { readonly imageBase: bigint; readonly unresolvedPointerSlots: ReadonlyMap; readonly zeroFillPointers: ReadonlyMap; + /** Mach-O loader fixups, used when interpreting method pointer slots. */ + readonly machoPointerFixups?: PointerFixups; } export function* openImageSteps( @@ -131,6 +141,30 @@ export function* openImageSteps( "unsupported-pointer-encoding", "Mach-O chained fixups are not decoded", ); + let machoPointerFixups: PointerFixups | undefined; + if (container.format === "macho") { + try { + const layout = parseMachoLayout(image, container.architecture); + machoPointerFixups = parsePointerFixups( + image, + layout.fixups, + layout.segments, + layout.baseAddress, + ); + } catch (cause) { + if (cause instanceof UnsupportedMachoMetadataFormatError) + throw new Il2cppBinaryFormatFailure( + "unsupported-container", + cause.message, + ); + if (cause instanceof RangeError || cause instanceof TypeError) + throw new Il2cppBinaryFormatFailure( + "malformed-container", + cause.message, + ); + throw cause; + } + } if (container.format === "pe" && container.image_base === undefined) throw new Il2cppBinaryFormatFailure( "malformed-container", @@ -153,6 +187,7 @@ export function* openImageSteps( imageBase, unresolvedPointerSlots: relocated?.unresolved ?? new Map(), zeroFillPointers: relocated?.zeroFillPointers ?? new Map(), + ...(machoPointerFixups === undefined ? {} : { machoPointerFixups }), }; } @@ -307,10 +342,25 @@ export const readSlot = ( address: bigint, ): bigint | "unknown" | null => { if (hasUnresolvedPointer(image, address)) return "unknown"; - const virtual = image.zeroFillPointers.get(address); - if (virtual !== undefined) return virtual; try { - return isZeroFilled(image, address, 8) ? 0n : readPointer(image, address); + const virtual = image.zeroFillPointers.get(address); + let raw: bigint | null; + if (virtual !== undefined) raw = virtual; + else if (isZeroFilled(image, address, 8)) raw = 0n; + else raw = readPointer(image, address); + if (raw === null) return null; + if (image.machoPointerFixups !== undefined) { + const resolution = image.machoPointerFixups.decode(address, raw); + if (resolution.kind === "bind" || resolution.kind === "unknown") + return "unknown"; + if (resolution.kind === "unsupported") + throw new Il2cppBinaryFormatFailure( + "unsupported-pointer-encoding", + resolution.reason, + ); + return resolution.target; + } + return raw; } catch (cause) { if (cause instanceof BinaryFormatFailure) throw new Il2cppBinaryFormatFailure("malformed-container", cause.message); diff --git a/src/native/elf/ElfNoteParser.ts b/src/native/elf/ElfNoteParser.ts index 37753f5cfa..e5277bc9c6 100644 --- a/src/native/elf/ElfNoteParser.ts +++ b/src/native/elf/ElfNoteParser.ts @@ -1,5 +1,4 @@ import { createHash } from "node:crypto"; -import { inflateSync } from "node:zlib"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { readElfFixedHeader } from "../../domain/elfFixedHeader.js"; import { @@ -14,12 +13,11 @@ import { elfObjectTypeName, gnuNoteTypeName, } from "../../domain/native/elfNotes.js"; +import { decodeElfSectionNameTable } from "../metadata/ElfSectionNameTable.js"; const PT_NOTE = 4; const SHT_NOTE = 7; const SHT_STRTAB = 3; -const SHF_COMPRESSED = 0x800; -const ELFCOMPRESS_ZLIB = 1; const HEX_DESCRIPTOR_LIMIT = 64; interface TextBudget { @@ -335,39 +333,32 @@ export const parseElfNotes = ( return fail("ELF section name table is not a string table."); const strOff = reader.word(namesHeader + (wide ? 24 : 16)); const strSize = reader.word(namesHeader + (wide ? 32 : 20)); - shstrtab = reader.slice(strOff, strSize); - if ((reader.word(namesHeader + 8) & SHF_COMPRESSED) !== 0) { - const compressionHeaderBytes = wide ? 24 : 12; - if (strSize < compressionHeaderBytes) - return fail("ELF compressed section name table header is truncated."); - const compressionType = reader.u32(strOff); - if (compressionType !== ELFCOMPRESS_ZLIB) { - shstrtab = null; - sectionNameLimitation = `ELF section names are unavailable because compression type ${String(compressionType)} is unsupported.`; - } else { - const uncompressedSize = reader.word(strOff + (wide ? 8 : 4)); - if (uncompressedSize > input.max_file_bytes - bytes.length) - throw new ArtifactReaderFailure( - "limit", - `ELF source and compressed section names exceed the ${String(input.max_file_bytes)}-byte memory budget.`, - { boundary: "memory" }, - ); - try { - shstrtab = inflateSync(shstrtab.subarray(compressionHeaderBytes), { - maxOutputLength: Math.max(1, uncompressedSize), - }); - } catch (cause: unknown) { - return fail( - `ELF compressed section name table could not be decompressed: ${cause instanceof Error ? cause.message : "invalid zlib data"}.`, - ); - } - if (shstrtab.length !== uncompressedSize) - return fail( - "ELF compressed section name table size is inconsistent.", - ); - text.limit = bytes.length + shstrtab.length; - } + const rawNames = reader.slice(strOff, strSize); + const flags = reader.word(namesHeader + 8); + const decoded = decodeElfSectionNameTable( + rawNames, + BigInt(flags), + header.elfClass, + reader.little, + input.max_file_bytes - bytes.length, + ); + if (decoded.state === "malformed") return fail(decoded.message); + if (decoded.state === "limit") + throw new ArtifactReaderFailure( + "limit", + `ELF source and compressed section names exceed the ${String(input.max_file_bytes)}-byte memory budget.`, + { boundary: "memory" }, + ); + if (decoded.state === "unsupported") { + shstrtab = null; + sectionNameLimitation = `ELF section names are unavailable because compression type ${String(decoded.compressionType)} is unsupported.`; + } else { + shstrtab = decoded.bytes; + if (decoded.compressed) text.limit = bytes.length + shstrtab.length; } + } else if (shnum !== 0) { + sectionNameLimitation = + "ELF section names are unavailable because no section-name string table is declared."; } const tables: ElfNotes["tables"] = []; const parsed: ReturnType[] = []; diff --git a/src/native/exports/NativeExportParser.test.ts b/src/native/exports/NativeExportParser.test.ts index b2409d4513..76f49b26ab 100644 --- a/src/native/exports/NativeExportParser.test.ts +++ b/src/native/exports/NativeExportParser.test.ts @@ -279,13 +279,25 @@ describe("ELF dynamic symbol exports", () => { name: "versioned_fn", type: "function", binding: "global", - version: { index: 2, name: "VER_1", hidden: false }, + version: { + index: 2, + name: "VER_1", + name_bytes_base64: null, + name_utf8_decodable: true, + hidden: false, + }, }, { name: "old_fn", type: "function", binding: "global", - version: { index: 3, name: "VER_0", hidden: true }, + version: { + index: 3, + name: "VER_0", + name_bytes_base64: null, + name_utf8_decodable: true, + hidden: true, + }, }, { name: "weak_obj", type: "object", binding: "weak", version: null }, { name: "ifunc_sym", type: "ifunc", binding: "global", version: null }, @@ -315,7 +327,9 @@ describe("ELF dynamic symbol exports", () => { exports: [{ name: "be_fn", value: "0x10000", size: "8" }], }); }); +}); +describe("ELF export version and name coverage", () => { it("marks versions unknown when the version table does not fit", () => { const result = parse( elfExportImage({ @@ -346,8 +360,20 @@ describe("ELF dynamic symbol exports", () => { expect( result.format === "elf" && result.exports.map(({ version }) => version), ).toEqual([ - { index: 7, name: null, hidden: false }, - { index: 7, name: null, hidden: false }, + { + index: 7, + name: null, + name_bytes_base64: null, + name_utf8_decodable: null, + hidden: false, + }, + { + index: 7, + name: null, + name_bytes_base64: null, + name_utf8_decodable: null, + hidden: false, + }, ]); expect(result.issues).toHaveLength(1); }); @@ -494,7 +520,13 @@ describe("ELF version requirements", () => { expect(result.exports).toMatchObject([ { name: "defined_version", - version: { index: 2, name: null, hidden: false }, + version: { + index: 2, + name: null, + name_bytes_base64: null, + name_utf8_decodable: null, + hidden: false, + }, }, ]); expect(result.coverage.status).toBe("partial"); @@ -572,7 +604,15 @@ describe("export inventory review regressions", () => { expect( result.format === "elf" && result.exports.map(({ version }) => version), - ).toEqual([{ index: 2, name: null, hidden: false }]); + ).toEqual([ + { + index: 2, + name: null, + name_bytes_base64: null, + name_utf8_decodable: null, + hidden: false, + }, + ]); expect(result.issues).toMatchObject([{ code: "version_unresolved" }]); expect(result.coverage.status).toBe("partial"); }); @@ -609,6 +649,65 @@ describe("ELF symbol-name text", () => { name_utf8_decodable: false, }); }); + + it.each([ + { + source: "definition", + result: () => + parse( + elfExportImage({ + symbols: [{ name: "defined", version: 2 }], + versionDefinitions: ["libfixture.so", "libcafé.so"], + }), + ), + versionIndex: 2, + }, + { + source: "requirement", + result: () => + parse( + elfExportImage({ + symbols: [{ name: "required", version: 3 }], + versionRequirements: [{ index: 3, name: "libcafé.so" }], + }), + ), + versionIndex: 3, + }, + ])( + "decodes UTF-8 version names from the $source table", + ({ result, versionIndex }) => { + const parsed = result(); + expect(parsed.format).toBe("elf"); + if (parsed.format !== "elf") throw new TypeError("expected ELF result"); + expect(parsed.exports[0]?.version).toEqual({ + index: versionIndex, + name: "libcafé.so", + name_bytes_base64: "bGliY2Fmw6kuc28=", + name_utf8_decodable: true, + hidden: false, + }); + expect(parsed.issues).toEqual([]); + expect(parsed.coverage.status).toBe("complete"); + }, + ); + + it("retains invalid UTF-8 version-name bytes with a byte-preserving display", () => { + const result = parse( + elfExportImage({ + symbols: [{ name: "defined", version: 2 }], + versionDefinitions: ["libfixture.so", "\u00ff"], + }), + ); + expect(result.format).toBe("elf"); + if (result.format !== "elf") throw new TypeError("expected ELF result"); + expect(result.exports[0]?.version).toEqual({ + index: 2, + name: "ÿ", + name_bytes_base64: "/w==", + name_utf8_decodable: false, + hidden: false, + }); + }); }); it("refuses formats other than PE and ELF", () => { diff --git a/src/native/exports/NativeExportParser.ts b/src/native/exports/NativeExportParser.ts index c663e98fcd..e44981778a 100644 --- a/src/native/exports/NativeExportParser.ts +++ b/src/native/exports/NativeExportParser.ts @@ -6,11 +6,7 @@ import { } from "../metadata/BinaryContainer.js"; import { architectureForElfMachine } from "../metadata/ElfImage.js"; import { isUtf8 } from "node:buffer"; -import { - readElfString, - readElfStringBytes, - readElfSymbol, -} from "../metadata/ElfSymbol.js"; +import { readElfStringBytes, readElfSymbol } from "../metadata/ElfSymbol.js"; import { readNativeDirectoryFromBuffer, type NativeSection, @@ -334,14 +330,31 @@ const versionSectionData = ( }; /** Version names by index, read from `.gnu.version_d` and `.gnu.version_r`. */ +type DecodedElfString = { + readonly name: string; + readonly name_bytes_base64: string | null; + readonly name_utf8_decodable: boolean; +}; + +const decodeElfString = (bytes: Buffer): DecodedElfString => { + const utf8 = isUtf8(bytes); + return { + name: bytes.toString(utf8 ? "utf8" : "latin1"), + name_bytes_base64: bytes.some((byte) => byte >= 0x80) + ? bytes.toString("base64") + : null, + name_utf8_decodable: utf8, + }; +}; + const readVersionNames = ( reader: BinaryReader, sections: readonly NativeSection[], issues: NativeExportIssue[], -): Map => { - const namesByIndex = new Map(); - const addName = (index: number, name: string): void => { - namesByIndex.set(index, name || null); +): Map => { + const namesByIndex = new Map(); + const addName = (index: number, name: Buffer): void => { + namesByIndex.set(index, name.length === 0 ? null : decodeElfString(name)); }; const verneed = sections.find(({ type }) => type === SHT_GNU_VERNEED); if (verneed !== undefined) { @@ -379,7 +392,10 @@ const readVersionNames = ( const versionIndex = reader.u16(auxAt + 6) & 0x7fff; const auxiliaryNext = reader.u32(auxAt + 12); try { - addName(versionIndex, readElfString(names, reader.u32(auxAt + 8))); + addName( + versionIndex, + readElfStringBytes(names, reader.u32(auxAt + 8)), + ); } catch (cause) { issues.push({ code: "version_unresolved", @@ -441,7 +457,7 @@ const readVersionNames = ( "Version definition name entry leaves its section", ); let auxiliary = offset + aux; - const versionName = readElfString( + const versionName = readElfStringBytes( names, reader.u32(table.offset + auxiliary), ); @@ -600,8 +616,8 @@ const parseElfExports = (bytes: Buffer, artifact: Artifact): NativeExports => { const versionIndex = raw & 0x7fff; if (versionIndex === 0) continue; if (versionIndex >= 2) { - const name = versionNames.get(versionIndex) ?? null; - if (name === null && !unresolvedVersions.has(versionIndex)) { + const decodedName = versionNames.get(versionIndex) ?? null; + if (decodedName === null && !unresolvedVersions.has(versionIndex)) { unresolvedVersions.add(versionIndex); issues.push({ code: "version_unresolved", @@ -611,18 +627,22 @@ const parseElfExports = (bytes: Buffer, artifact: Artifact): NativeExports => { } version = { index: versionIndex, - name, + name: decodedName?.name ?? null, + name_bytes_base64: decodedName?.name_bytes_base64 ?? null, + name_utf8_decodable: decodedName?.name_utf8_decodable ?? null, hidden: (raw & 0x8000) !== 0, }; } } let name: string; - let rawName: Buffer; let nameUtf8Decodable: boolean; + let rawNameBase64: string | null; try { - rawName = readElfStringBytes(names, symbol.name_offset); - nameUtf8Decodable = isUtf8(rawName); - name = rawName.toString(nameUtf8Decodable ? "utf8" : "latin1"); + const rawName = readElfStringBytes(names, symbol.name_offset); + const decodedName = decodeElfString(rawName); + nameUtf8Decodable = decodedName.name_utf8_decodable; + name = decodedName.name; + rawNameBase64 = decodedName.name_bytes_base64; } catch (cause) { issues.push({ code: "name_unreadable", @@ -639,13 +659,11 @@ const parseElfExports = (bytes: Buffer, artifact: Artifact): NativeExports => { }); continue; } - const rawNameBase64 = rawName.some((byte) => byte >= 0x80) - ? rawName.toString("base64") - : null; retain( name, typeof version === "object" ? (version?.name ?? null) : null, rawNameBase64, + typeof version === "object" ? (version?.name_bytes_base64 ?? null) : null, ); exports.push({ name, diff --git a/src/native/metadata/BinaryContainer.ts b/src/native/metadata/BinaryContainer.ts index fc063529c9..27904c9678 100644 --- a/src/native/metadata/BinaryContainer.ts +++ b/src/native/metadata/BinaryContainer.ts @@ -50,7 +50,6 @@ export interface LoadRegion { /** Parsed section facts; consumer metadata validity is separate from native structure. */ export type ImageSection = { - readonly name: string; readonly offset: number; readonly size: number; readonly file_backed: boolean; @@ -59,6 +58,8 @@ export type ImageSection = { } & ( | { readonly format: "elf"; + /** Null when the ELF name table is unavailable or this name is unreadable. */ + readonly name: string | null; readonly type: number; readonly address: bigint; readonly declared_range: { @@ -68,6 +69,7 @@ export type ImageSection = { } | { readonly format: "pe"; + readonly name: string; readonly virtual_address: number; readonly virtual_size: number; readonly raw_size: number; @@ -75,6 +77,7 @@ export type ImageSection = { } | { readonly format: "macho"; + readonly name: string; readonly section_name: string; readonly type: number; readonly address: bigint; @@ -107,6 +110,8 @@ export interface BinaryContainer { }[]; /** Mach-O load-command observation, when available. */ readonly macho_chained_fixups?: boolean; + /** Section-name lookup was incomplete; absence by name is not established. */ + readonly section_names_limitation?: string; readonly mappings: readonly FileMapping[]; readonly load_regions?: readonly LoadRegion[]; /** File-backed ranges the native loader marks executable. */ diff --git a/src/native/metadata/ElfImage.ts b/src/native/metadata/ElfImage.ts index 5e1ec28571..846baa8619 100644 --- a/src/native/metadata/ElfImage.ts +++ b/src/native/metadata/ElfImage.ts @@ -67,7 +67,7 @@ export const readElfImage = (bytes: Buffer): BinaryContainer => { zeroFills.push({ address: address + size, size: memorySize - size }); } for (const section of directory.sections) { - if (section.name === "" || section.name_state === "unavailable") continue; + if (section.name === "") continue; const range = declaredFileRange(bytes.length, section.offset, section.size); const file_backed = section.type !== 8 && range.file_backed; const compressed = (section.flags & 0x800n) !== 0n; @@ -81,7 +81,7 @@ export const readElfImage = (bytes: Buffer): BinaryContainer => { if (section.name === null && !read_only) continue; sections.push({ format: "elf", - name: section.name ?? "", + name: section.name, type: section.type, address: section.address, declared_range: { offset: section.offset, size: section.size }, @@ -101,6 +101,9 @@ export const readElfImage = (bytes: Buffer): BinaryContainer => { executable_mappings: executableMappings, zero_fills: zeroFills, sections, + ...(directory.section_names_limitation === undefined + ? {} + : { section_names_limitation: directory.section_names_limitation }), }; }; diff --git a/src/native/metadata/ElfSectionNameTable.ts b/src/native/metadata/ElfSectionNameTable.ts new file mode 100644 index 0000000000..0c47f02475 --- /dev/null +++ b/src/native/metadata/ElfSectionNameTable.ts @@ -0,0 +1,73 @@ +import { inflateSync } from "node:zlib"; + +const SHF_COMPRESSED = 0x800n; +const ELFCOMPRESS_ZLIB = 1; + +export type ElfSectionNameTableResult = + | { + readonly state: "available"; + readonly bytes: Buffer; + readonly compressed: boolean; + } + | { readonly state: "unsupported"; readonly compressionType: number } + | { readonly state: "malformed"; readonly message: string } + | { + readonly state: "limit"; + readonly requiredBytes: bigint; + readonly maximumBytes: number; + }; + +/** Decode the ELF section-name table once for consumers that need its names. */ +export const decodeElfSectionNameTable = ( + bytes: Buffer, + flags: bigint, + bits: 32 | 64, + little: boolean, + maximumDecodedBytes: number, +): ElfSectionNameTableResult => { + if ((flags & SHF_COMPRESSED) === 0n) + return { state: "available", bytes, compressed: false }; + + const headerBytes = bits === 64 ? 24 : 12; + if (bytes.length < headerBytes) + return { + state: "malformed", + message: "ELF compressed section-name table header is truncated", + }; + const compressionType = little + ? bytes.readUInt32LE(0) + : bytes.readUInt32BE(0); + if (compressionType !== ELFCOMPRESS_ZLIB) + return { state: "unsupported", compressionType }; + + const declaredSize = + bits === 64 + ? little + ? bytes.readBigUInt64LE(8) + : bytes.readBigUInt64BE(8) + : BigInt(little ? bytes.readUInt32LE(4) : bytes.readUInt32BE(4)); + if (declaredSize > BigInt(maximumDecodedBytes)) + return { + state: "limit", + requiredBytes: declaredSize, + maximumBytes: maximumDecodedBytes, + }; + const expected = Number(declaredSize); + let decoded: Buffer; + try { + decoded = inflateSync(bytes.subarray(headerBytes), { + maxOutputLength: Math.max(1, expected), + }); + } catch (cause: unknown) { + return { + state: "malformed", + message: `ELF compressed section-name table could not be decompressed: ${cause instanceof Error ? cause.message : "invalid zlib data"}`, + }; + } + if (decoded.length !== expected) + return { + state: "malformed", + message: "ELF compressed section-name table size is inconsistent", + }; + return { state: "available", bytes: decoded, compressed: true }; +}; diff --git a/src/native/metadata/NativeSectionDirectory.ts b/src/native/metadata/NativeSectionDirectory.ts index 0be595b1f7..538689e32b 100644 --- a/src/native/metadata/NativeSectionDirectory.ts +++ b/src/native/metadata/NativeSectionDirectory.ts @@ -5,6 +5,7 @@ import { BinaryResourceFailure, declaredFileRange, } from "./BinaryContainer.js"; +import { decodeElfSectionNameTable } from "./ElfSectionNameTable.js"; /** A borrowed positional source. Drivers own admission, cancellation and file identity. */ export interface NativeReadRequest { @@ -33,6 +34,7 @@ export interface NativeSectionDirectory { readonly machine: number; readonly elf_os_abi: number | null; readonly flags: number; + readonly section_names_limitation?: string; readonly program_offset: bigint; readonly program_count: number; readonly program_width: number; @@ -138,6 +140,7 @@ export function* readElfSectionDirectory( ); } const sections: NativeSection[] = []; + let section_names_limitation: string | undefined; if (count !== 0) { if (sectionWidth < (wide ? 64 : 40) || namesIndex >= count) reject("ELF section width or name-table index is invalid"); @@ -154,11 +157,31 @@ export function* readElfSectionDirectory( const h = namesIndex * sectionWidth; if (table.u32(h + 4) !== 3) reject("ELF section name table is not a string table"); - names = yield request( + const rawNames = yield request( fileSize, table.word(h + (wide ? 24 : 16), bits), table.word(h + (wide ? 32 : 20), bits), ); + const decoded = decodeElfSectionNameTable( + rawNames, + table.word(h + 8, bits), + bits, + little, + Math.max(0, STRUCTURE_BYTES - rawNames.length), + ); + if (decoded.state === "malformed") return reject(decoded.message); + if (decoded.state === "limit") + throw new BinaryResourceFailure( + "structure", + STRUCTURE_BYTES, + `Decoded ELF section names require ${String(decoded.requiredBytes)} bytes, above the ${String(decoded.maximumBytes)}-byte remaining structural budget`, + ); + if (decoded.state === "unsupported") + section_names_limitation = `ELF section names are unavailable because compression type ${String(decoded.compressionType)} is unsupported`; + else names = decoded.bytes; + } else if (count > 1) { + section_names_limitation = + "ELF section names are unavailable because no section-name string table is declared"; } const decodedNames = new Map< number, @@ -171,30 +194,36 @@ export function* readElfSectionDirectory( let name: string | null = null; let name_state: NativeSection["name_state"] = "unavailable"; if (names !== null) { - if (nameOffset >= names.length) - reject("ELF section name offset is outside its string table"); - let decoded = decodedNames.get(nameOffset); - if (decoded === undefined) { - const window = names.subarray( - nameOffset, - Math.min(nameOffset + 65536, names.length), - ); - const end = window.indexOf(0); - nameBytes += end < 0 ? window.length : end + 1; - if (nameBytes > STRUCTURE_BYTES) - throw new BinaryResourceFailure( - "structure", - STRUCTURE_BYTES, - "Decoded ELF section names exceed the 16 MiB structural budget", + if (nameOffset >= names.length) { + section_names_limitation ??= + "ELF section names are incomplete because a name offset is outside its string table"; + } else { + let decoded = decodedNames.get(nameOffset); + if (decoded === undefined) { + const window = names.subarray( + nameOffset, + Math.min(nameOffset + 65536, names.length), ); - decoded = { - name: end < 0 ? null : window.toString("latin1", 0, end), - state: end < 0 ? "truncated" : "known", - }; - decodedNames.set(nameOffset, decoded); + const end = window.indexOf(0); + nameBytes += end < 0 ? window.length : end + 1; + if (nameBytes > STRUCTURE_BYTES) + throw new BinaryResourceFailure( + "structure", + STRUCTURE_BYTES, + "Decoded ELF section names exceed the 16 MiB structural budget", + ); + decoded = { + name: end < 0 ? null : window.toString("latin1", 0, end), + state: end < 0 ? "truncated" : "known", + }; + if (end < 0) + section_names_limitation ??= + "ELF section names are unavailable because a name is not NUL-terminated"; + decodedNames.set(nameOffset, decoded); + } + name_state = decoded.state; + name = decoded.name; } - name_state = decoded.state; - name = decoded.name; } const type = table.u32(h + 4); const offset = table.word(h + (wide ? 24 : 16), bits); @@ -224,6 +253,9 @@ export function* readElfSectionDirectory( little, machine, flags: r.u32(wide ? 48 : 36), + ...(section_names_limitation === undefined + ? {} + : { section_names_limitation }), program_offset, program_count, program_width, diff --git a/src/rust/RustBinaryImage.ts b/src/rust/RustBinaryImage.ts index 6cb485b75b..ba81f3a07c 100644 --- a/src/rust/RustBinaryImage.ts +++ b/src/rust/RustBinaryImage.ts @@ -28,6 +28,7 @@ export interface RustBinaryImage { readonly byte_order: RustBinary["byte_order"]; readonly auditable_metadata: RustAuditableMetadata | null; readonly rustc_commit: RustBinary["rustc_commit"]; + readonly section_names_limitation?: string; } /** Read dependency metadata from container sections without executing the image. */ @@ -44,6 +45,9 @@ export const readRustBinaryImage = (bytes: Buffer): RustBinaryImage => { ? null : readDependency(bytes, container.format, dependency), rustc_commit: rustcCommit(bytes, container), + ...(container.section_names_limitation === undefined + ? {} + : { section_names_limitation: container.section_names_limitation }), }; }; diff --git a/src/rust/RustBinaryProvider.ts b/src/rust/RustBinaryProvider.ts index 015f67eb39..797dbed443 100644 --- a/src/rust/RustBinaryProvider.ts +++ b/src/rust/RustBinaryProvider.ts @@ -101,7 +101,12 @@ export class RustBinaryProvider implements RustBinaryPort { selected.fileOffset, ); const report = { - ...image, + format: image.format, + architecture: image.architecture, + bits: image.bits, + byte_order: image.byte_order, + auditable_metadata: image.auditable_metadata, + rustc_commit: image.rustc_commit, artifact: { path: input.path, sha256: snapshot.sha256, @@ -172,11 +177,21 @@ const limitationsFor = ( "Embedded dependency metadata is file content. It does not authenticate package names, versions, sources, or the compiler that produced the binary.", "Only the .dep-v0 dependency section and read-only compiler path strings are inspected. Functions, types, and runtime behavior are not recovered.", "Resource guards bound the selected file to 256 MiB, a dependency section to 1 MiB, inflated JSON to the smaller of 8 MiB and 64 times the section size, and the complete report to 8 MiB. Package and dependency counts are admitted against that output budget before schema cloning, graph objects, or reverse-edge allocation.", - ...(image.auditable_metadata === null + ...(image.section_names_limitation === undefined + ? [] + : [image.section_names_limitation]), + ...(image.auditable_metadata === null && + image.section_names_limitation === undefined ? [ "No .dep-v0 section was found. Absence of .dep-v0 does not mean the binary has no dependencies.", ] : []), + ...(image.auditable_metadata === null && + image.section_names_limitation !== undefined + ? [ + "The presence of .dep-v0 could not be determined because ELF section names are unavailable.", + ] + : []), ...unknownFormatLimitation(image.auditable_metadata?.format_version), ...(image.auditable_metadata?.packages.some((pkg) => pkg.kind === null) ? [ diff --git a/tests/acceptance/applications/nativeExportsVersionRequirements.test.ts b/tests/acceptance/applications/nativeExportsVersionRequirements.test.ts index 764b3dade4..8dfa85eeef 100644 --- a/tests/acceptance/applications/nativeExportsVersionRequirements.test.ts +++ b/tests/acceptance/applications/nativeExportsVersionRequirements.test.ts @@ -42,3 +42,44 @@ cliTest( }, 30_000, ); + +cliTest( + "preserves UTF-8 GNU version names and their exact bytes", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-elf-version-utf8-"); + const path = join(root, "versioned.so"); + await writeFile( + path, + elfExportImage({ + symbols: [{ name: "plain", version: 2 }], + versionDefinitions: ["libfixture.so", "libcafé.so"], + }), + ); + + const result = await cli.run({ + arguments: ["inspect-native-exports", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = contract.outputSchema.parse(result.json); + expect(nativeExportsSchema.parse(evidence.normalized_result)).toMatchObject( + { + format: "elf", + coverage: { status: "complete", exports: 1 }, + issues: [], + exports: [ + { + name: "plain", + version: { + index: 2, + name: "libcafé.so", + name_bytes_base64: "bGliY2Fmw6kuc28=", + name_utf8_decodable: true, + hidden: false, + }, + }, + ], + }, + ); + }, + 30_000, +); diff --git a/tests/acceptance/il2cppMetadataCli.test.ts b/tests/acceptance/il2cppMetadataCli.test.ts index d8dbe610db..7a0f816201 100644 --- a/tests/acceptance/il2cppMetadataCli.test.ts +++ b/tests/acceptance/il2cppMetadataCli.test.ts @@ -33,6 +33,7 @@ import { import { createElfFixture, createIl2cppBinaryFixture, + createIl2cppDyldBindFixture, createUniversalFixture, FIXTURE_BINARY_BASE, FIXTURE_METHOD_ADDRESSES, @@ -852,6 +853,52 @@ cliTest( 30000, ); +cliTest( + "keeps a dyld-bound method slot unresolved through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-il2cpp-dyld-bind-"); + const path = join(root, "global-metadata.dat"); + const binaryPath = join(root, "GameAssembly.dylib"); + await writeFile(path, createLinkedIl2cppMetadataFixture().bytes); + await writeFile(binaryPath, createIl2cppDyldBindFixture(0n)); + + const result = await runFileCli(cli, { + arguments: [ + "inspect-il2cpp-metadata", + path, + "--binary", + binaryPath, + "--json", + ], + }); + expect(result.exitCode, result.stderr).toBe(0); + const report = parseCliMetadata(result.json); + expect(report.method_addresses[1]).toEqual({ + state: "unknown", + address: null, + rva: null, + }); + expect(report.method_addresses[4]).toEqual({ + state: "no-body", + address: null, + rva: null, + }); + + const { client, call } = await connectLocalToolsMcp(); + const response = await call("inspect_il2cpp_metadata", { + path, + binary_path: binaryPath, + detail: "complete", + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect( + parseCliMetadata(response.structuredContent).method_addresses, + ).toEqual(report.method_addresses); + await client.ping(); + }, + 30000, +); + cliTest( "keeps nested IL2CPP generic arguments on their declaring type components", async ({ cli }) => { diff --git a/tests/acceptance/rustBinaryCli.test.ts b/tests/acceptance/rustBinaryCli.test.ts index e30e1c8961..aa04f97b22 100644 --- a/tests/acceptance/rustBinaryCli.test.ts +++ b/tests/acceptance/rustBinaryCli.test.ts @@ -8,6 +8,7 @@ import { expect } from "vitest"; import { toolContract } from "../../src/contracts/toolContracts.js"; import { machoImageWithUnknownCommand } from "../../src/artifacts/apple/MachoImage.fixture.js"; import { + compressRustElfSectionNames, createRustImage, createRustImageWithGoSection, RUSTC_HASH, @@ -258,6 +259,57 @@ cliTest("returns null metadata when .dep-v0 is absent", async ({ cli }) => { ); }); +cliTest( + "reads compressed ELF names and keeps unsupported compression unknown", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-rust-elf-compressed-names-", + ); + for (const fixture of [ + { name: "zlib", type: 1, supported: true }, + { name: "unknown", type: 2, supported: false }, + ]) { + const path = join(root, `${fixture.name}.elf`); + await writeFile( + path, + compressRustElfSectionNames( + createRustImage({ format: "elf" }), + fixture.type, + ), + ); + const result = await cli.run({ + arguments: ["inspect-rust-binary", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_rust_binary").outputSchema.parse( + result.json, + ); + const report = rustBinarySchema.parse(evidence.normalized_result); + if (fixture.supported) { + expect(report.auditable_metadata).toMatchObject({ + section_name: ".dep-v0", + packages: [ + { name: "itoa", version: "1.0.18" }, + { name: "sample", version: "0.1.0", root: true }, + ], + dependency_edges: [{ from_index: 1, to_index: 0 }], + }); + } else { + expect(report.auditable_metadata).toBeNull(); + expect(report.limitations.join("\n")).toContain( + "ELF section names are unavailable because compression type 2 is unsupported", + ); + expect(report.limitations.join("\n")).toContain( + "The presence of .dep-v0 could not be determined", + ); + expect(report.limitations.join("\n")).not.toContain( + "No .dep-v0 section was found", + ); + } + } + }, +); + cliTest( "keeps arm64_32 in Rust CLI Evidence when dependency metadata is absent", async ({ cli }) => { diff --git a/tests/acceptance/tools/protobufCapture.test.ts b/tests/acceptance/tools/protobufCapture.test.ts index 8c725b8de4..98bb8b3905 100644 --- a/tests/acceptance/tools/protobufCapture.test.ts +++ b/tests/acceptance/tools/protobufCapture.test.ts @@ -559,6 +559,12 @@ captureTest( const trailerCases = [ ["valid", "grpc-status", "0", "complete"], + [ + "opaque", + "grpc-status", + "0\r\nx-note: caf\u00e9\r\nx-after: kept", + "partial", + ], ["uppercase", "Grpc-Status", "0", "partial"], ["nul", "grpc-status", "0\u0000bad", "partial"], ["lf", "grpc-status", "0\nbad", "partial"], @@ -582,7 +588,10 @@ captureTest.for( async ({ framing, name, headerName, value, status }, { cli, mcp }) => { const root = await createTestTempDirectory("rea-grpc-web-trailers-"); const path = join(root, `${framing}-${name}.har`); - const trailer = Buffer.from(`${headerName}: ${value}\r\n`); + const trailer = Buffer.from( + `${headerName}: ${value}\r\n`, + name === "opaque" ? "latin1" : "utf8", + ); const body = Buffer.concat([ protobufFrame(Buffer.from([8, 1])), protobufFrame(trailer, 128), @@ -618,6 +627,12 @@ captureTest.for( ], fields: [{ number: 1, wire_type: "varint", value: "1" }], }); + if (name === "opaque") { + expect(report.records[0]?.protobuf?.[0]?.frames[1]?.trailers).toEqual([ + { name: "grpc-status", value: "0" }, + { name: "x-after", value: "kept" }, + ]); + } const reply = await mcp.call("inspect_web_network_capture", { capture_path: path, format: "har", diff --git a/tests/boundary/evm/evmoleInterfaceProvider.test.ts b/tests/boundary/evm/evmoleInterfaceProvider.test.ts index 15aa1ae5cf..9340f1ec72 100644 --- a/tests/boundary/evm/evmoleInterfaceProvider.test.ts +++ b/tests/boundary/evm/evmoleInterfaceProvider.test.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { access, readFile, writeFile } from "node:fs/promises"; +import { access, readFile, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, onTestFinished } from "vitest"; import { z } from "zod"; @@ -562,6 +562,41 @@ it.runIf(!unsupportedHost)( ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } }); }, ); +it.runIf(!unsupportedHost)( + "classifies selected carrier path-resolution errnos as invalid input", + async () => { + const { root } = await fixture(); + const loop = join(root, "loop"); + await symlink("loop", loop); + const cases = [ + { code: "ELOOP", path: join(loop, "carrier.bin") }, + { code: "ENAMETOOLONG", path: join(root, "x".repeat(256)) }, + ] as const; + const service = new EvmInterfaceService(new EvmoleInterfaceProvider({})); + try { + for (const { code, path } of cases) { + const result = await service.inspect({ path, encoding: "raw" }); + if (result.ok) throw new Error(`Expected ${code} path failure`); + expect(result.error._tag).toBe("AnalysisInputError"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["path"], + reason: "invalid_value", + message: expect.stringContaining(code), + }, + ], + }, + }); + } + } finally { + await service.close(); + } + }, +); + it.runIf(unsupportedHost)( "reports unverified host coverage before acquiring a provider", async () => { diff --git a/tests/boundary/filesystem/nativeExports.test.ts b/tests/boundary/filesystem/nativeExports.test.ts index d29f4221cf..4355ceb1f7 100644 --- a/tests/boundary/filesystem/nativeExports.test.ts +++ b/tests/boundary/filesystem/nativeExports.test.ts @@ -53,12 +53,15 @@ describe("native export stable filesystem boundary", () => { } await service.close(); }); +}); +describe("native export encoding and failures", () => { it("keeps ELF symbol bytes while exposing valid UTF-8 text", async () => { const root = await createTestTempDirectory("rea-native-export-unicode-"); const path = join(root, "libfixture.so"); const bytes = elfExportImage({ - symbols: [{ name: "café" }, { name: "\u00ff" }], + symbols: [{ name: "café", version: 2 }, { name: "\u00ff" }], + versionDefinitions: ["libfixture.so", "libcafé.so"], }); await writeFile(path, bytes); const service = createNativeExportsService(); @@ -71,6 +74,12 @@ describe("native export stable filesystem boundary", () => { name: "café", name_bytes_base64: "Y2Fmw6k=", name_utf8_decodable: true, + version: { + index: 2, + name: "libcafé.so", + name_bytes_base64: "bGliY2Fmw6kuc28=", + name_utf8_decodable: true, + }, }, { name: "ÿ", @@ -85,10 +94,26 @@ describe("native export stable filesystem boundary", () => { for (const row of olderRows.exports) { delete row.name_bytes_base64; delete row.name_utf8_decodable; + const version = row.version; + if (version !== null && typeof version === "object") { + Reflect.deleteProperty(version, "name_bytes_base64"); + Reflect.deleteProperty(version, "name_utf8_decodable"); + } } expect(nativeExportsSchema.parse(olderRows)).toMatchObject({ exports: [ - { name: "café", name_bytes_base64: null, name_utf8_decodable: null }, + { + name: "café", + name_bytes_base64: null, + name_utf8_decodable: null, + version: { + index: 2, + name: "libcafé.so", + name_bytes_base64: null, + name_utf8_decodable: null, + hidden: false, + }, + }, { name: "ÿ", name_bytes_base64: null, name_utf8_decodable: null }, ], }); diff --git a/tests/boundary/go/goBinaryProvider.test.ts b/tests/boundary/go/goBinaryProvider.test.ts index e59484d03e..51f44bdef5 100644 --- a/tests/boundary/go/goBinaryProvider.test.ts +++ b/tests/boundary/go/goBinaryProvider.test.ts @@ -99,6 +99,28 @@ it("keeps the header-search budget distinct from a worker CPU limit", async () = expect(projected.remediation.action).not.toMatch(/observed signal/u); }); +it("reports unreadable ELF names alongside the no-record limitation", async () => { + const bytes = createGoBinaryFixture({ omitBuildInfo: true }).bytes; + const nameTableHeader = 256 + 64; + bytes.writeBigUInt64LE(0x800n, nameTableHeader + 8); + bytes.writeBigUInt64LE(24n, nameTableHeader + 32); + bytes.writeUInt32LE(2, 768); + const root = await createTestTempDirectory("rea-go-unknown-section-names-"); + const path = join(root, "unknown-names.elf"); + await writeFile(path, bytes); + + const result = await new GoBinaryProvider().inspect({ path }); + if (!result.ok) throw result.error; + expect(result.value.build_info).toBeNull(); + expect(result.value.limitations).toContain( + "No recognized Go build-info record was found in the inspected image; absent metadata does not establish that the binary is not Go.", + ); + expect(result.value.limitations).toContain( + "ELF section names are unavailable because compression type 2 is unsupported", + ); + expect(result.value).not.toHaveProperty("section_names_limitation"); +}); + it.skipIf(process.platform === "win32")( "rejects a symlink without replacing its selected file identity", async () => { diff --git a/tests/boundary/gpu/codeObjects.test.ts b/tests/boundary/gpu/codeObjects.test.ts index 053326c875..84c5009780 100644 --- a/tests/boundary/gpu/codeObjects.test.ts +++ b/tests/boundary/gpu/codeObjects.test.ts @@ -706,6 +706,50 @@ it("iterates mixed plain/CCOB bundles without counting flat records as nesting", expect(result.value.kernels).toHaveLength(20); expect(result.value.coverage.discovery).toBe("complete"); }); +it("does not recognize a CCOB signature after its first byte is corrupted", async () => { + const bytes = ccobFixture(3, "zlib"); + bytes[0] = bytes[0]! | 0x80; + const result = await inspect(bytes); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisUnsupportedTargetError" }, + }); +}); +it("requires the PTX version directive before any module content", async () => { + const result = await inspect(Buffer.from(`unexpected-prefix ${ptxFixture}`)); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisUnsupportedTargetError" }, + }); +}); +it("retains an embedded CCOB with a corrupted signature as unsupported", async () => { + const bytes = ccobFixture(3, "zlib"); + bytes[0] = bytes[0]! | 0x80; + const result = await inspect( + elfFixture(62, [{ name: ".hip_fatbin", bytes }]), + ); + if (!result.ok) throw result.error; + expect(result.value.coverage.discovery).toBe("partial"); + expect(result.value.kernels).toEqual([]); + expect(result.value.objects).toContainEqual( + expect.objectContaining({ + kind: "native-section", + inspection: "unsupported", + limitations: expect.arrayContaining([ + "No supported CUDA/HIP container or PTX declarations were identified", + ]), + }), + ); +}); +it("does not dispatch a high-bit-corrupted DOS signature to the PE parser", async () => { + const bytes = peGpuFixture(Buffer.from(ptxFixture)); + bytes[0] = bytes[0]! | 0x80; + const result = await inspect(bytes); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisUnsupportedTargetError" }, + }); +}); it("uses each legacy CCOB v1 stream boundary across alignment padding", async () => { const first = ccobFixture(1, "zlib"), second = ccobFixture(2, "zlib"), diff --git a/tests/boundary/il2cpp/il2cppBinaryFile.test.ts b/tests/boundary/il2cpp/il2cppBinaryFile.test.ts index 8d8f8c47bb..c8c656191b 100644 --- a/tests/boundary/il2cpp/il2cppBinaryFile.test.ts +++ b/tests/boundary/il2cpp/il2cppBinaryFile.test.ts @@ -8,6 +8,7 @@ import { readIl2cppMetadataForLinking } from "../../../src/il2cpp/Il2cppMetadata import { createElfFixture, createIl2cppBinaryFixture, + createIl2cppDyldBindFixture, createUniversalFixture, createSplitPointerPeFixture, FIXTURE_BINARY_BASE, @@ -577,6 +578,22 @@ it("reports a symbol-relocated method slot as unknown rather than no-body", () = expect(linked.method_addresses[0]).toEqual(expectedAddresses[0]); }); +it.each([0n, FIXTURE_BINARY_BASE + 0x210n])( + "keeps a dyld-bound method slot unknown regardless of its raw word %s", + (raw) => { + const linked = link(createIl2cppDyldBindFixture(raw)); + expect(linked.method_addresses[1]).toEqual(expectedAddress("unknown")); + expect(linked.method_addresses[0]).toEqual(expectedAddresses[0]); + expect(linked.method_addresses[4]).toEqual(expectedAddress("no-body")); + }, +); + +it("keeps unbound Mach-O method words classified from their values", () => { + const linked = link(createIl2cppBinaryFixture()); + expect(linked.method_addresses[1]).toEqual(expectedAddresses[1]); + expect(linked.method_addresses[4]).toEqual(expectedAddress("no-body")); +}); + it.each([ ["PC32", 2], ["ABS32", 10], diff --git a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts index 3dcf01b8d2..7c62d97d18 100644 --- a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts +++ b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts @@ -319,7 +319,12 @@ it("records a portable export inventory through MCP without a binary target", as const root = await createTestTempDirectory("rea-native-exports-mcp-"); const path = join(root, "libfixture.so"); const bytes = elfExportImage({ - symbols: [{ name: "exported_fn" }, { name: "hidden_fn", visibility: 2 }], + symbols: [ + { name: "exported_fn" }, + { name: "hidden_fn", visibility: 2 }, + { name: "versioned_fn", version: 2 }, + ], + versionDefinitions: ["libfixture.so", "libcafé.so"], }); await writeFile(path, bytes); const session = createTestBinarySession(() => { @@ -348,6 +353,22 @@ it("records a portable export inventory through MCP without a binary target", as name: "inspect_native_exports", arguments: { path }, }); + expect( + parseEvidence(response.structuredContent).normalized_result, + ).toMatchObject({ + format: "elf", + exports: [ + { name: "exported_fn", version: null }, + { + name: "versioned_fn", + version: { + name: "libcafé.so", + name_bytes_base64: "bGliY2Fmw6kuc28=", + name_utf8_decodable: true, + }, + }, + ], + }); assertRecordedMcpEvidence( response, "inspect_native_exports", diff --git a/tests/boundary/model/modelArtifactViewIdentity.test.ts b/tests/boundary/model/modelArtifactViewIdentity.test.ts new file mode 100644 index 0000000000..34e8988a1f --- /dev/null +++ b/tests/boundary/model/modelArtifactViewIdentity.test.ts @@ -0,0 +1,78 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { inspectAnalysisView } from "../../../src/application/analysisView/AnalysisViewService.js"; +import { createModelArtifactService } from "../../../src/composition/model.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { freezeJsonSnapshot } from "../../../src/domain/immutableJson.js"; +import { ggufFixture } from "../../fixtures/model/model.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it("checks each model parent subject independently of cached declarations", async () => { + const root = await createTestTempDirectory("rea-model-view-identity-"); + const path = join(root, "selected.gguf"); + await writeFile(path, ggufFixture().bytes); + const service = createModelArtifactService(); + try { + const inspected = await service.inspect({ + path, + hash: "full", + detail: "complete", + }); + if (!inspected.ok) throw inspected.error; + const original = inspected.value; + if (original.subject === null) throw new Error("Missing full-file subject"); + const report = freezeJsonSnapshot(original.normalized_result); + const parent = (name: string, targetPath: string, digest: string) => + createEvidence( + { path: targetPath, sha256: digest, format: "file" }, + original.provider, + { + operation: original.operation, + parameters: { path, hash: "full", fixture: name }, + result: report, + confidence: "observed", + limitations: original.limitations, + }, + ); + const digest = original.subject.digest.sha256; + const valid = parent("valid", path, digest); + const conflicting = [ + parent("wrong-path", join(root, "other.gguf"), digest), + parent("wrong-digest", path, "00".repeat(32)), + ]; + const project = (record: typeof valid) => + inspectAnalysisView( + { + source: { + kind: "retained-evidence", + evidence_id: record.evidence_id, + }, + view: { kind: "summary" }, + }, + (id) => (id === record.evidence_id ? record : undefined), + ); + for (const record of conflicting) + expect(record.normalized_result).toBe(report); + for (const phase of ["cold", "warm"]) { + for (const record of conflicting) { + expect(await project(record), phase).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisInputError", + issues: [{ path: ["source", "subject"], reason: "invalid_value" }], + }, + }); + } + const projected = await project(valid); + if (!projected.ok) throw projected.error; + expect(projected.value.subject).toEqual(original.subject); + expect(projected.value.normalized_result).toMatchObject({ + artifact: { path, sha256: digest }, + }); + } + } finally { + await service.close(); + } +}); diff --git a/tests/boundary/rust/rustBinaryImage.test.ts b/tests/boundary/rust/rustBinaryImage.test.ts index 56a752bf73..672b7e4d40 100644 --- a/tests/boundary/rust/rustBinaryImage.test.ts +++ b/tests/boundary/rust/rustBinaryImage.test.ts @@ -4,6 +4,7 @@ import { createRustImage, createRustImageWithGoSection, createRustHashOverflowImage, + compressRustElfSectionNames, RUSTC_HASH, rustDependencyPayload, } from "../../fixtures/rust/image.js"; @@ -127,6 +128,43 @@ it("returns null metadata when the dependency section is absent", () => { ]); }); +it("reads dependency metadata through a compressed ELF section-name table", () => { + const image = readRustBinaryImage( + compressRustElfSectionNames(createRustImage({ format: "elf" })), + ); + expect(image.auditable_metadata).toMatchObject({ + section_name: ".dep-v0", + file_offset: 1024, + packages, + dependency_edges: [{ from_index: 1, to_index: 0 }], + }); +}); + +it("keeps dependency presence unknown for unsupported ELF name compression", () => { + const image = readRustBinaryImage( + compressRustElfSectionNames(createRustImage({ format: "elf" }), 2), + ); + expect(image.auditable_metadata).toBeNull(); + expect(image.section_names_limitation).toMatch( + /compression type 2 is unsupported/u, + ); + expect(image.rustc_commit?.hashes).toEqual([ + { hash: RUSTC_HASH, file_offset: 2048 }, + ]); +}); + +it("keeps dependency presence unknown when its ELF name offset is unreadable", () => { + const bytes = createRustImage({ format: "elf" }); + bytes.writeUInt32LE(0xffff, 384); + + const image = readRustBinaryImage(bytes); + expect(image.auditable_metadata).toBeNull(); + expect(image.section_names_limitation).toMatch(/outside its string table/u); + expect(image.rustc_commit?.hashes).toEqual([ + { hash: RUSTC_HASH, file_offset: 2048 }, + ]); +}); + it("finds rustc paths in read-only ELF load data without section headers", () => { const bytes = createRustImage({ format: "elf", diff --git a/tests/fixtures/il2cpp/binary.ts b/tests/fixtures/il2cpp/binary.ts index 84cdfad95c..27795c0795 100644 --- a/tests/fixtures/il2cpp/binary.ts +++ b/tests/fixtures/il2cpp/binary.ts @@ -187,6 +187,27 @@ export const createIl2cppBinaryFixture = ( return bytes; }; +/** Add one classic dyld bind for a method slot, retaining its chosen raw word. */ +export const createIl2cppDyldBindFixture = (raw: bigint): Buffer => { + const bytes = Buffer.from(createIl2cppBinaryFixture()); + const command = 104; + const streamOffset = 0xf00; + const stream = Buffer.concat([ + Buffer.from([0x3e, 0x40]), + Buffer.from("_FixtureMethod\0", "utf8"), + Buffer.from([0x51, 0x70, 0x80, 0x06, 0x90, 0x00]), + ]); + bytes.writeUInt32LE(2, 16); + bytes.writeUInt32LE(120, 20); + bytes.writeUInt32LE(0x80000022, command); + bytes.writeUInt32LE(48, command + 4); + bytes.writeUInt32LE(streamOffset, command + 16); + bytes.writeUInt32LE(stream.length, command + 20); + stream.copy(bytes, streamOffset); + bytes.writeBigUInt64LE(raw, 0x300); + return bytes; +}; + /** Wrap thin Mach-O slices in a 32-bit universal header, page aligned. */ export const createUniversalFixture = ( slices: readonly { readonly cpu: number; readonly bytes: Buffer }[], diff --git a/tests/fixtures/rust/image.ts b/tests/fixtures/rust/image.ts index c80a40ad24..b557bf8967 100644 --- a/tests/fixtures/rust/image.ts +++ b/tests/fixtures/rust/image.ts @@ -17,6 +17,38 @@ export const rustDependencyPayload = ( }, ): Buffer => deflateSync(Buffer.from(JSON.stringify(document))); +/** Compress the ELF section-name table without changing other sections. */ +export const compressRustElfSectionNames = ( + bytes: Buffer, + compressionType = 1, +): Buffer => { + const sectionOffset = Number(bytes.readBigUInt64LE(40)); + const sectionWidth = bytes.readUInt16LE(58); + const namesIndex = bytes.readUInt16LE(62); + const nameHeader = sectionOffset + namesIndex * sectionWidth; + const namesOffset = Number(bytes.readBigUInt64LE(nameHeader + 24)); + const namesSize = Number(bytes.readBigUInt64LE(nameHeader + 32)); + const names = Buffer.from( + bytes.subarray(namesOffset, namesOffset + namesSize), + ); + const compressed = deflateSync(names); + const compressionHeader = Buffer.alloc(24); + compressionHeader.writeUInt32LE(compressionType, 0); + compressionHeader.writeBigUInt64LE(BigInt(names.length), 8); + compressionHeader.writeBigUInt64LE(1n, 16); + compressionHeader.copy(bytes, namesOffset); + compressed.copy(bytes, namesOffset + compressionHeader.length); + bytes.writeBigUInt64LE( + bytes.readBigUInt64LE(nameHeader + 8) | 0x800n, + nameHeader + 8, + ); + bytes.writeBigUInt64LE( + BigInt(compressionHeader.length + compressed.length), + nameHeader + 32, + ); + return bytes; +}; + export const RUSTC_HASH = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const RUSTC_PATH = `/rustc/${RUSTC_HASH}/library/core`;