mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(rust): preserve JSON field identity and PE stream extents
Reject duplicate decoded JSON names before object conversion. Use one checksummed zlib stream parse and retained PE FileAlignment to distinguish exact zero padding from trailing payload bytes. Exercise public CLI and stdio MCP. Fixes #6814 Fixes #6793
This commit is contained in:
@@ -126,10 +126,16 @@ distinct hashes leave a single commit unknown.
|
||||
The selected file is limited to 256 MiB. A dependency section is limited to 1
|
||||
MiB compressed. Inflated JSON is limited to the smaller of 8 MiB and 64 times
|
||||
the compressed size. The complete report is limited to 8 MiB. Empty sections,
|
||||
truncated or trailing zlib bytes, invalid JSON, unknown fields, dependency
|
||||
truncated streams, nonpadding bytes after a zlib stream, invalid JSON, duplicate
|
||||
object fields, unknown fields, dependency
|
||||
indices outside the package list, more than one root package, and compressed
|
||||
dependency sections are typed failures. A cycle is reported and is not walked.
|
||||
|
||||
For PE images with `VirtualSize` zero, the raw-size fallback can include file
|
||||
alignment bytes. REA excludes a zero suffix only when the raw section size is
|
||||
the zlib stream length rounded up to the declared `FileAlignment`; nonzero or
|
||||
oversized suffixes and truncated streams remain failures.
|
||||
|
||||
A missing section returns success with `auditable_metadata: null` and a
|
||||
limitation. Absence of `.dep-v0` does not mean the binary has no dependencies.
|
||||
Unknown embedded format revisions are retained in `format_version` and
|
||||
|
||||
@@ -73,6 +73,7 @@ export type ImageSection = {
|
||||
readonly virtual_address: number;
|
||||
readonly virtual_size: number;
|
||||
readonly raw_size: number;
|
||||
readonly file_alignment: number;
|
||||
readonly characteristics: number;
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -90,6 +90,7 @@ export const readPeImage = (bytes: Buffer): BinaryContainer => {
|
||||
return { virtual_address: reader.u32(entry), size: reader.u32(entry + 4) };
|
||||
});
|
||||
const imageBase = reader.word(optional + (bits === 64 ? 24 : 28), bits);
|
||||
const fileAlignment = reader.u32(optional + 36);
|
||||
const headerMapping: FileMapping = {
|
||||
address: imageBase,
|
||||
offset: 0,
|
||||
@@ -127,6 +128,7 @@ export const readPeImage = (bytes: Buffer): BinaryContainer => {
|
||||
virtualAddress,
|
||||
virtualSize,
|
||||
rawSize: size,
|
||||
fileAlignment,
|
||||
}),
|
||||
);
|
||||
const address = imageBase + BigInt(virtualAddress);
|
||||
@@ -185,6 +187,7 @@ const peImageSection = ({
|
||||
virtualAddress,
|
||||
virtualSize,
|
||||
rawSize,
|
||||
fileAlignment,
|
||||
}: {
|
||||
readonly reader: BinaryReader;
|
||||
readonly name: string;
|
||||
@@ -194,6 +197,7 @@ const peImageSection = ({
|
||||
readonly virtualAddress: number;
|
||||
readonly virtualSize: number;
|
||||
readonly rawSize: number;
|
||||
readonly fileAlignment: number;
|
||||
}): ImageSection => {
|
||||
const range = declaredFileRange(
|
||||
reader.bytes.length,
|
||||
@@ -212,6 +216,7 @@ const peImageSection = ({
|
||||
virtual_address: virtualAddress,
|
||||
virtual_size: virtualSize,
|
||||
raw_size: rawSize,
|
||||
file_alignment: fileAlignment,
|
||||
characteristics,
|
||||
...range,
|
||||
read_only: readOnly,
|
||||
|
||||
@@ -14,6 +14,8 @@ const section = (document: unknown) =>
|
||||
0,
|
||||
deflateSync(Buffer.from(JSON.stringify(document))),
|
||||
);
|
||||
const sectionJson = (document: string) =>
|
||||
readAuditableSection(".dep-v0", 0, deflateSync(Buffer.from(document)));
|
||||
|
||||
it("preserves package order, omitted kind, and the root index", () => {
|
||||
const parsed = section({
|
||||
@@ -194,6 +196,23 @@ it("rejects invalid JSON, trailing zlib bytes, and an empty section", () => {
|
||||
).toThrow(RustAuditableFormatFailure);
|
||||
});
|
||||
|
||||
it("rejects duplicate decoded JSON fields before object conversion", () => {
|
||||
for (const document of [
|
||||
'{"packages":[],"packages":[]}',
|
||||
'{"packages":[{"name":"alpha","name":"beta","version":"1","source":"local"}]}',
|
||||
'{"packages":[{"name":"alpha","version":"1","source":"local","dependencies":[0],"dependencies":[]}]}',
|
||||
'{"packages":[],"meta":{"name":"alpha","na\\u006de":"beta"}}',
|
||||
])
|
||||
expect(() => sectionJson(document)).toThrow(
|
||||
/duplicate field "(?:packages|name|dependencies)"/u,
|
||||
);
|
||||
expect(
|
||||
sectionJson(
|
||||
'{"packages":[{"name":"alpha","version":"1","source":"local"}]}',
|
||||
).packages,
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("stops an inflate that exceeds the section ratio", () => {
|
||||
const raw = Buffer.alloc(8000);
|
||||
const compressed = deflateSync(raw);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { deflateSync, inflateSync } from "node:zlib";
|
||||
import { nodeCodecResult } from "../compression/NodeCodecResult.js";
|
||||
import { inflateSync } from "node:zlib";
|
||||
import { visit } from "jsonc-parser";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
cyclicDependencyIndices,
|
||||
@@ -81,6 +83,7 @@ export const readAuditableSection = (
|
||||
sectionName: RustAuditableMetadata["section_name"],
|
||||
fileOffset: number,
|
||||
bytes: Buffer,
|
||||
options: { readonly peFileAlignment?: number | undefined } = {},
|
||||
): RustAuditableMetadata => {
|
||||
if (bytes.length === 0)
|
||||
throw new RustAuditableFormatFailure(
|
||||
@@ -95,16 +98,16 @@ export const readAuditableSection = (
|
||||
},
|
||||
"Dependency section exceeds the 1 MiB compressed limit",
|
||||
);
|
||||
const inflated = inflateSection(bytes);
|
||||
const document = parseDocument(inflated);
|
||||
const inflated = inflateSection(bytes, options.peFileAlignment);
|
||||
const document = parseDocument(inflated.bytes);
|
||||
const packages = normalizePackages(document.packages);
|
||||
const root = rootIndex(packages);
|
||||
return {
|
||||
section_name: sectionName,
|
||||
file_offset: fileOffset,
|
||||
section_bytes: bytes.length,
|
||||
compressed_bytes: bytes.length,
|
||||
uncompressed_bytes: inflated.length,
|
||||
section_bytes: inflated.compressedBytes,
|
||||
compressed_bytes: inflated.compressedBytes,
|
||||
uncompressed_bytes: inflated.bytes.length,
|
||||
format_version: document.format ?? null,
|
||||
packages,
|
||||
root_package_index: root,
|
||||
@@ -118,78 +121,45 @@ export const readAuditableSection = (
|
||||
};
|
||||
};
|
||||
|
||||
const inflateSection = (bytes: Buffer): Buffer => {
|
||||
const inflateSection = (
|
||||
bytes: Buffer,
|
||||
peFileAlignment: number | undefined,
|
||||
): { readonly bytes: Buffer; readonly compressedBytes: number } => {
|
||||
const maximum = Math.min(
|
||||
RUST_UNCOMPRESSED_BYTES,
|
||||
bytes.length * RUST_INFLATE_RATIO,
|
||||
);
|
||||
try {
|
||||
const inflated = inflateSync(bytes, {
|
||||
maxOutputLength: maximum,
|
||||
...(rejectsTrailingJunk() ? { rejectGarbageAfterEnd: true } : {}),
|
||||
});
|
||||
if (
|
||||
!rejectsTrailingJunk() &&
|
||||
shortestPrefix(bytes, inflated, maximum) !== bytes.length
|
||||
)
|
||||
const result = nodeCodecResult(
|
||||
inflateSync(bytes, {
|
||||
info: true,
|
||||
maxOutputLength: maximum,
|
||||
}),
|
||||
);
|
||||
const compressedBytes = result.consumedBytes;
|
||||
if (peFileAlignment !== undefined && peFileAlignment > 0) {
|
||||
const padding = bytes.subarray(compressedBytes);
|
||||
const alignedRawSize =
|
||||
Math.ceil(compressedBytes / peFileAlignment) * peFileAlignment;
|
||||
if (bytes.length !== alignedRawSize || padding.some((byte) => byte !== 0))
|
||||
throw new RustAuditableFormatFailure(
|
||||
"malformed",
|
||||
"Dependency section has bytes after the zlib stream",
|
||||
);
|
||||
return { bytes: result.buffer, compressedBytes };
|
||||
}
|
||||
if (compressedBytes !== bytes.length)
|
||||
throw new RustAuditableFormatFailure(
|
||||
"malformed",
|
||||
"Dependency section has bytes after the zlib stream",
|
||||
);
|
||||
return inflated;
|
||||
return { bytes: result.buffer, compressedBytes };
|
||||
} catch (cause) {
|
||||
if (cause instanceof RustAuditableFormatFailure) throw cause;
|
||||
throw inflateFailure(cause, maximum);
|
||||
}
|
||||
};
|
||||
|
||||
let trailingJunkRejected: boolean | undefined;
|
||||
|
||||
const rejectsTrailingJunk = (): boolean => {
|
||||
if (trailingJunkRejected !== undefined) return trailingJunkRejected;
|
||||
const trailed = Buffer.concat([
|
||||
deflateSync(Buffer.from("a")),
|
||||
Buffer.from([0]),
|
||||
]);
|
||||
try {
|
||||
inflateSync(trailed, { rejectGarbageAfterEnd: true });
|
||||
trailingJunkRejected = false;
|
||||
} catch (cause) {
|
||||
trailingJunkRejected =
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
cause.code === "ERR_TRAILING_JUNK_AFTER_STREAM_END";
|
||||
}
|
||||
return trailingJunkRejected;
|
||||
};
|
||||
|
||||
const shortestPrefix = (
|
||||
input: Buffer,
|
||||
output: Buffer,
|
||||
maximum: number,
|
||||
): number => {
|
||||
let low = 0;
|
||||
let high = input.length;
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
if (prefixMatches(input.subarray(0, mid), output, maximum)) high = mid;
|
||||
else low = mid + 1;
|
||||
}
|
||||
return high;
|
||||
};
|
||||
|
||||
const prefixMatches = (
|
||||
prefix: Buffer,
|
||||
output: Buffer,
|
||||
maximum: number,
|
||||
): boolean => {
|
||||
try {
|
||||
return inflateSync(prefix, { maxOutputLength: maximum }).equals(output);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const inflateFailure = (cause: unknown, maximum: number): Error => {
|
||||
const code =
|
||||
cause instanceof Error && "code" in cause ? String(cause.code) : "";
|
||||
@@ -222,6 +192,44 @@ const parseDocument = (bytes: Buffer): z.infer<typeof documentSchema> => {
|
||||
);
|
||||
}
|
||||
assertDepth(text);
|
||||
let invalidJson = false;
|
||||
let duplicate: string | undefined;
|
||||
const objectNames: Set<string>[] = [];
|
||||
visit(
|
||||
text,
|
||||
{
|
||||
onObjectBegin: () => {
|
||||
objectNames.push(new Set());
|
||||
},
|
||||
onObjectProperty: (name) => {
|
||||
const names = objectNames.at(-1);
|
||||
if (names === undefined) return;
|
||||
if (names.has(name)) duplicate ??= name;
|
||||
names.add(name);
|
||||
},
|
||||
onObjectEnd: () => {
|
||||
objectNames.pop();
|
||||
},
|
||||
onError: () => {
|
||||
invalidJson = true;
|
||||
},
|
||||
},
|
||||
{
|
||||
allowTrailingComma: false,
|
||||
disallowComments: true,
|
||||
},
|
||||
);
|
||||
if (invalidJson) {
|
||||
throw new RustAuditableFormatFailure(
|
||||
"malformed",
|
||||
"Dependency section JSON is invalid",
|
||||
);
|
||||
}
|
||||
if (duplicate !== undefined)
|
||||
throw new RustAuditableFormatFailure(
|
||||
"malformed",
|
||||
`Dependency JSON contains duplicate field ${JSON.stringify(duplicate)}`,
|
||||
);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
|
||||
@@ -109,6 +109,15 @@ const readDependency = (
|
||||
format === "macho" ? "__DATA,.dep-v0" : ".dep-v0",
|
||||
section.offset,
|
||||
bytes.subarray(section.offset, section.offset + section.size),
|
||||
{
|
||||
peFileAlignment:
|
||||
section.format === "pe" &&
|
||||
section.virtual_size === 0 &&
|
||||
section.file_alignment > 0 &&
|
||||
section.raw_size % section.file_alignment === 0
|
||||
? section.file_alignment
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const rustcCommit = (
|
||||
|
||||
@@ -394,6 +394,78 @@ cliTest("reports malformed zlib as invalid input", async ({ cli }) => {
|
||||
});
|
||||
});
|
||||
|
||||
cliTest(
|
||||
"rejects duplicate Rust dependency JSON through CLI and stdio MCP",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-rust-cli-duplicate-json-");
|
||||
const { client, call } = await connectLocalToolsMcp();
|
||||
const path = join(root, "duplicate.elf");
|
||||
await writeFile(
|
||||
path,
|
||||
createRustImage({
|
||||
format: "elf",
|
||||
payload: deflateSync(Buffer.from('{"packages":[],"packages":[]}')),
|
||||
includeRustc: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-rust-binary", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: { operation: "inspect_rust_binary" },
|
||||
});
|
||||
const response = await call("inspect_rust_binary", { path });
|
||||
expect(parseMcpToolError(response).error).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: { operation: "inspect_rust_binary" },
|
||||
});
|
||||
await client.ping();
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"reads PE cargo-auditable data padded to FileAlignment through CLI and MCP",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-rust-cli-pe-padding-");
|
||||
const { client, call } = await connectLocalToolsMcp();
|
||||
const path = join(root, "padded.exe");
|
||||
const bytes = createRustImage({ format: "pe", includeRustc: false });
|
||||
const compressedBytes = rustDependencyPayload().length;
|
||||
const alignedSize = 512;
|
||||
expect(compressedBytes).toBeLessThan(alignedSize);
|
||||
bytes.writeUInt32LE(alignedSize, 152 + 36); // OptionalHeader.FileAlignment
|
||||
bytes.writeUInt32LE(0, 264 + 8); // .dep-v0 VirtualSize
|
||||
bytes.writeUInt32LE(alignedSize, 264 + 16); // .dep-v0 SizeOfRawData
|
||||
await writeFile(path, bytes);
|
||||
|
||||
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,
|
||||
);
|
||||
expect(
|
||||
rustBinarySchema.parse(evidence.normalized_result).auditable_metadata,
|
||||
).toMatchObject({
|
||||
compressed_bytes: compressedBytes,
|
||||
packages: [
|
||||
{ name: "itoa", version: "1.0.18" },
|
||||
{ name: "sample", version: "0.1.0", root: true },
|
||||
],
|
||||
});
|
||||
const response = await call("inspect_rust_binary", { path });
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
expect(response.structuredContent).toEqual(evidence);
|
||||
await client.ping();
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"releases a failed Rust descriptor close before projecting the CLI failure",
|
||||
async ({ cli }) => {
|
||||
|
||||
@@ -118,6 +118,44 @@ it("ignores PE file-alignment padding after the dependency content", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes zero-virtual-size PE raw padding without accepting payload junk", () => {
|
||||
const payloadBytes = rustDependencyPayload().length;
|
||||
const entry = 264;
|
||||
const bytes = createRustImage({ format: "pe", includeRustc: false });
|
||||
bytes.writeUInt32LE(0, entry + 8);
|
||||
bytes.writeUInt32LE(512, entry + 16);
|
||||
bytes.writeUInt32LE(512, 188); // Optional-header FileAlignment.
|
||||
expect(readRustBinaryImage(bytes).auditable_metadata).toMatchObject({
|
||||
section_bytes: payloadBytes,
|
||||
compressed_bytes: payloadBytes,
|
||||
packages,
|
||||
});
|
||||
|
||||
const payloadOffset = bytes.readUInt32LE(entry + 20);
|
||||
const nonzeroSuffix = Buffer.from(bytes);
|
||||
nonzeroSuffix[payloadOffset + payloadBytes] = 1;
|
||||
expect(() => readRustBinaryImage(nonzeroSuffix)).toThrow(
|
||||
/bytes after the zlib stream/u,
|
||||
);
|
||||
|
||||
const truncated = Buffer.from(bytes);
|
||||
truncated[payloadOffset + payloadBytes - 1] =
|
||||
truncated[payloadOffset + payloadBytes - 1]! ^ 0xff;
|
||||
expect(() => readRustBinaryImage(truncated)).toThrow(/complete zlib stream/u);
|
||||
|
||||
const nonzeroVirtualSize = Buffer.from(bytes);
|
||||
nonzeroVirtualSize.writeUInt32LE(payloadBytes + 1, entry + 8);
|
||||
expect(() => readRustBinaryImage(nonzeroVirtualSize)).toThrow(
|
||||
/bytes after the zlib stream/u,
|
||||
);
|
||||
|
||||
const unknownAlignment = Buffer.from(bytes);
|
||||
unknownAlignment.writeUInt32LE(0, 188);
|
||||
expect(() => readRustBinaryImage(unknownAlignment)).toThrow(
|
||||
/bytes after the zlib stream/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null metadata when the dependency section is absent", () => {
|
||||
const image = readRustBinaryImage(
|
||||
createRustImage({ format: "elf", omitSection: true }),
|
||||
|
||||
Reference in New Issue
Block a user