mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<TrailerFacts, TrailerFailure> => {
|
||||
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;
|
||||
|
||||
@@ -143,10 +143,13 @@ const read = (parent: AnalysisViewParent): Result<Checked, AnalysisError> => {
|
||||
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<Checked, AnalysisError> => {
|
||||
},
|
||||
]),
|
||||
);
|
||||
if (cached !== undefined) return ok(cached);
|
||||
const summary = summarizeModel(report);
|
||||
const {
|
||||
artifact: _artifact,
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
]),
|
||||
|
||||
@@ -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}.`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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}.`,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
+16
-1
@@ -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<string>();
|
||||
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 }),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
-9
@@ -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
|
||||
|
||||
@@ -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<bigint, number>;
|
||||
readonly zeroFillPointers: ReadonlyMap<bigint, bigint>;
|
||||
/** 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<bigint, number>(),
|
||||
zeroFillPointers: relocated?.zeroFillPointers ?? new Map<bigint, bigint>(),
|
||||
...(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);
|
||||
|
||||
@@ -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<typeof toEntry>[] = [];
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<number, string | null> => {
|
||||
const namesByIndex = new Map<number, string | null>();
|
||||
const addName = (index: number, name: string): void => {
|
||||
namesByIndex.set(index, name || null);
|
||||
): Map<number, DecodedElfString | null> => {
|
||||
const namesByIndex = new Map<number, DecodedElfString | null>();
|
||||
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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
? [
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Vendored
+21
@@ -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 }[],
|
||||
|
||||
Vendored
+32
@@ -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`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user