diff --git a/docs/rust-binaries.md b/docs/rust-binaries.md index 7c5c4d5478..4f9e4c6b22 100644 --- a/docs/rust-binaries.md +++ b/docs/rust-binaries.md @@ -93,8 +93,10 @@ container format, architecture, word size, and byte order. When a `.dep-v0` section is present, `auditable_metadata` contains: - `section_name`, `.dep-v0` for ELF and PE or `__DATA,.dep-v0` for Mach-O; -- `file_offset` and `section_bytes`, the exact file range of the compressed section; -- `compressed_bytes` and `uncompressed_bytes`; +- `file_offset` and `section_bytes`, the exact file range of the dependency + section in the selected artifact, including accepted PE file-alignment bytes; +- `compressed_bytes`, the zlib stream length used for the inflate-ratio limit, + and `uncompressed_bytes`; - `format_version`, the embedded integer, or `null` when the field is omitted; - `packages`, in the section's original order, with `name`, `version`, `source`, `kind`, `dependencies`, `features`, and `root`; @@ -123,9 +125,10 @@ distinct hashes leave a single commit unknown. ## Limits and failures -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, +The selected file is limited to 256 MiB. A dependency section's zlib stream is +limited to 1 MiB compressed. Inflated JSON is limited to the smaller of 8 MiB +and 64 times the compressed stream length. The complete report is limited to +8 MiB. Empty sections, 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 diff --git a/src/contracts/rust/rustToolContracts.ts b/src/contracts/rust/rustToolContracts.ts index 87bc1b22d7..c4694c0b0c 100644 --- a/src/contracts/rust/rustToolContracts.ts +++ b/src/contracts/rust/rustToolContracts.ts @@ -16,7 +16,7 @@ export const RUST_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_rust_binary"), kind: "artifact-provider", description: - "Inspect the cargo-auditable crate dependency list embedded in an explicit local ELF, PE or thin Mach-O image, or the slice of a universal Mach-O named by architecture (read without lipo; artifact stays the universal file, selected_slice records the slice and offsets stay file offsets). Returns the artifact SHA-256, the .dep-v0 section file offset and size, compressed and uncompressed sizes, format version, packages in their original order, the root package index, and dependency edges by index. Omitted or explicit complete detail preserves the full report. Optional detail summary retains that complete Evidence and returns source ranges, counts and parent_evidence_id. Optional view directly selects packages or edges in this call; it cannot be combined with explicit complete detail. Query the same retained facts with inspect_analysis_view kind rust: exact names/versions or package indices, edge from_indices/to_indices, fields and matching-row pagination. Row indices are original and scoped to the parent Evidence; empty filters select none. Default package fields omit dependency vectors; explicitly select dependencies for direct index references. Every matching observed version is retained. No current runtime state is inferred from historical reads. Reads zlib-compressed JSON without installing Rust, opening a disassembler, executing the target, writing temporary files or accessing the network. A missing section is null metadata with a limitation and is not proof that the binary has no dependencies. A rustc commit hash is reported only as an inference when a /rustc/<40-hex>/ path is present in read-only data. Resource guards bound files to 256 MiB, dependency sections to 1 MiB, inflated JSON to the smaller of 8 MiB and 64 times the compressed size, and the complete report to 8 MiB. Malformed, unsupported and oversized inputs receive typed failures rather than partial success.", + "Inspect the cargo-auditable crate dependency list embedded in an explicit local ELF, PE or thin Mach-O image, or the slice of a universal Mach-O named by architecture (read without lipo; artifact stays the universal file, selected_slice records the slice and offsets stay file offsets). Returns the artifact SHA-256, the .dep-v0 section file offset and size, compressed and uncompressed sizes, format version, packages in their original order, the root package index, and dependency edges by index. Omitted or explicit complete detail preserves the full report. Optional detail summary retains that complete Evidence and returns source ranges, counts and parent_evidence_id. Optional view directly selects packages or edges in this call; it cannot be combined with explicit complete detail. Query the same retained facts with inspect_analysis_view kind rust: exact names/versions or package indices, edge from_indices/to_indices, fields and matching-row pagination. Row indices are original and scoped to the parent Evidence; empty filters select none. Default package fields omit dependency vectors; explicitly select dependencies for direct index references. Every matching observed version is retained. No current runtime state is inferred from historical reads. Reads zlib-compressed JSON without installing Rust, opening a disassembler, executing the target, writing temporary files or accessing the network. A missing section is null metadata with a limitation and is not proof that the binary has no dependencies. A rustc commit hash is reported only as an inference when a /rustc/<40-hex>/ path is present in read-only data. Resource guards bound files to 256 MiB, compressed dependency streams to 1 MiB, inflated JSON to the smaller of 8 MiB and 64 times the consumed compressed-stream length, and the complete report to 8 MiB. Malformed, unsupported and oversized inputs receive typed failures rather than partial success.", inputSchema: inspectRustBinaryRequestSchema, outputSchema: evidenceResultOf( z.union([ diff --git a/src/domain/rust/rustBinary.ts b/src/domain/rust/rustBinary.ts index e3d484c994..982acb5a29 100644 --- a/src/domain/rust/rustBinary.ts +++ b/src/domain/rust/rustBinary.ts @@ -53,7 +53,9 @@ const dependencyEdgeSchema = z.strictObject({ export const rustAuditableMetadataSchema = z.strictObject({ section_name: z.enum([".dep-v0", "__DATA,.dep-v0"]), file_offset: index, + /** Stored section range, including accepted PE file-alignment bytes. */ section_bytes: z.int().positive(), + /** Bytes consumed by the zlib stream; the inflate ratio uses this size. */ compressed_bytes: z.int().positive(), uncompressed_bytes: z.int().positive(), /** Original embedded revision; null means the source omitted the field. */ @@ -138,12 +140,12 @@ const checkSection = ( ): void => { if ( !inside(fileBytes, metadata.file_offset, metadata.section_bytes) || - metadata.compressed_bytes !== metadata.section_bytes + metadata.compressed_bytes > metadata.section_bytes ) issue( context, ["auditable_metadata", "file_offset"], - "Dependency section range is outside the selected artifact or disagrees with its compressed size.", + "Dependency section range is outside the selected artifact or shorter than its compressed stream.", ); }; diff --git a/src/rust/RustAuditableReader.test.ts b/src/rust/RustAuditableReader.test.ts index a37b6cd3de..bc60927689 100644 --- a/src/rust/RustAuditableReader.test.ts +++ b/src/rust/RustAuditableReader.test.ts @@ -225,6 +225,30 @@ it("stops an inflate that exceeds the section ratio", () => { ).toThrow(/1 MiB/); }); +it("retains the compressed-stream limit for a PE section with padding", () => { + const compressed = deflateSync(Buffer.alloc(RUST_SECTION_BYTES), { + level: 0, + }); + expect(compressed.length).toBeGreaterThan(RUST_SECTION_BYTES); + const fileAlignment = 512; + const stored = Buffer.alloc( + Math.ceil(compressed.length / fileAlignment) * fileAlignment, + ); + compressed.copy(stored); + expect(() => + readAuditableSection(".dep-v0", 0, stored, { + peFileAlignment: fileAlignment, + }), + ).toThrow( + expect.objectContaining({ + constraint: { + resource: "memory", + reported_limits: { maximum_bytes: RUST_SECTION_BYTES }, + }, + }), + ); +}); + it.each([0, 8])("reads documented cargo-auditable revision %i", (format) => { const parsed = section({ format, diff --git a/src/rust/RustAuditableReader.ts b/src/rust/RustAuditableReader.ts index 754c963ea1..b03f315162 100644 --- a/src/rust/RustAuditableReader.ts +++ b/src/rust/RustAuditableReader.ts @@ -11,9 +11,9 @@ import { jsonValueByteLength } from "../domain/jsonValueByteLength.js"; /** Selected-file admission limit shared with the stable artifact reader. */ export const RUST_BINARY_INPUT_BYTES = 256 * 1024 * 1024; -/** Compressed `.dep-v0` section limit. */ +/** Compressed zlib stream limit. */ export const RUST_SECTION_BYTES = 1024 * 1024; -/** Inflated JSON may grow by at most this multiple of the section size. */ +/** Inflated JSON may grow by at most this multiple of the compressed stream. */ export const RUST_INFLATE_RATIO = 64; /** Absolute inflated JSON limit, applied together with the ratio. */ export const RUST_UNCOMPRESSED_BYTES = 8 * 1024 * 1024; @@ -90,7 +90,10 @@ export const readAuditableSection = ( "malformed", "Dependency section is empty", ); - if (bytes.length > RUST_SECTION_BYTES) + if ( + bytes.length > RUST_SECTION_BYTES && + options.peFileAlignment === undefined + ) throw new RustAuditableResourceFailure( { resource: "memory", @@ -105,7 +108,7 @@ export const readAuditableSection = ( return { section_name: sectionName, file_offset: fileOffset, - section_bytes: inflated.compressedBytes, + section_bytes: bytes.length, compressed_bytes: inflated.compressedBytes, uncompressed_bytes: inflated.bytes.length, format_version: document.format ?? null, @@ -124,40 +127,64 @@ export const readAuditableSection = ( const inflateSection = ( bytes: Buffer, peFileAlignment: number | undefined, -): { readonly bytes: Buffer; readonly compressedBytes: number } => { - const maximum = Math.min( +): { + readonly bytes: Buffer; + readonly compressedBytes: number; +} => { + // Keep inflate allocation bounded before consumed input is known. PE storage + // can include zero alignment bytes, so the ratio is checked after decoding. + const allocationMaximum = Math.min( RUST_UNCOMPRESSED_BYTES, bytes.length * RUST_INFLATE_RATIO, ); + let result: ReturnType; try { - const result = nodeCodecResult( + result = nodeCodecResult( inflateSync(bytes, { info: true, - maxOutputLength: maximum, + maxOutputLength: allocationMaximum, }), ); - 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) + } catch (cause) { + throw inflateFailure(cause, allocationMaximum); + } + const compressedBytes = result.consumedBytes; + if (compressedBytes > RUST_SECTION_BYTES) + throw new RustAuditableResourceFailure( + { + resource: "memory", + reported_limits: { maximum_bytes: RUST_SECTION_BYTES }, + }, + "Dependency section exceeds the 1 MiB compressed limit", + ); + 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 }; - } catch (cause) { - if (cause instanceof RustAuditableFormatFailure) throw cause; - throw inflateFailure(cause, maximum); + } else if (compressedBytes !== bytes.length) { + throw new RustAuditableFormatFailure( + "malformed", + "Dependency section has bytes after the zlib stream", + ); } + const compressedMaximum = Math.min( + RUST_UNCOMPRESSED_BYTES, + compressedBytes * RUST_INFLATE_RATIO, + ); + if (result.buffer.length > compressedMaximum) + throw new RustAuditableResourceFailure( + { + resource: "memory", + reported_limits: { maximum_bytes: compressedMaximum }, + }, + "Inflated dependency JSON exceeds the size limit", + ); + return { bytes: result.buffer, compressedBytes }; }; const inflateFailure = (cause: unknown, maximum: number): Error => { diff --git a/tests/acceptance/rustBinaryCli.test.ts b/tests/acceptance/rustBinaryCli.test.ts index 20e71cc66e..96fe60517f 100644 --- a/tests/acceptance/rustBinaryCli.test.ts +++ b/tests/acceptance/rustBinaryCli.test.ts @@ -449,15 +449,25 @@ cliTest( const evidence = toolContract("inspect_rust_binary").outputSchema.parse( result.json, ); - expect( - rustBinarySchema.parse(evidence.normalized_result).auditable_metadata, - ).toMatchObject({ + const metadata = rustBinarySchema.parse( + evidence.normalized_result, + ).auditable_metadata; + expect(metadata).toMatchObject({ + section_bytes: alignedSize, compressed_bytes: compressedBytes, packages: [ { name: "itoa", version: "1.0.18" }, { name: "sample", version: "0.1.0", root: true }, ], }); + expect(metadata?.uncompressed_bytes).toBeLessThanOrEqual( + (metadata?.compressed_bytes ?? 0) * 64, + ); + expect(evidence.locations).toContainEqual({ + kind: "file-offset-range", + start: metadata?.file_offset, + end: (metadata?.file_offset ?? 0) + alignedSize, + }); const response = await call("inspect_rust_binary", { path }); expect(response.isError, JSON.stringify(response)).not.toBe(true); expect(response.structuredContent).toEqual(evidence); @@ -466,6 +476,53 @@ cliTest( 30000, ); +cliTest( + "limits PE inflate ratio by consumed zlib bytes through CLI and stdio MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-rust-cli-pe-ratio-"); + const { client, call } = await connectLocalToolsMcp(); + const path = join(root, "over-ratio.exe"); + const document = Buffer.concat([ + Buffer.from(JSON.stringify({ packages: [] })), + Buffer.alloc(24 * 1024, 0x20), + ]); + const compressed = deflateSync(document); + const alignedSize = 512; + expect(compressed.length).toBeLessThan(alignedSize); + expect(document.length).toBeGreaterThan(compressed.length * 64); + expect(document.length).toBeLessThanOrEqual(alignedSize * 64); + + const bytes = createRustImage({ + format: "pe", + payload: compressed, + includeRustc: false, + }); + 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 expectedFailure = { + code: "resource_constraint", + retryable: false, + details: { + operation: "inspect_rust_binary", + resource: "memory", + reported_limits: { maximum_bytes: compressed.length * 64 }, + }, + }; + const result = await cli.run({ + arguments: ["inspect-rust-binary", path, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject(expectedFailure); + const response = await call("inspect_rust_binary", { path }); + expect(parseMcpToolError(response).error).toMatchObject(expectedFailure); + await client.ping(); + }, + 30000, +); + cliTest( "releases a failed Rust descriptor close before projecting the CLI failure", async ({ cli }) => { diff --git a/tests/boundary/rust/rustBinaryImage.test.ts b/tests/boundary/rust/rustBinaryImage.test.ts index 6dfec5c166..a48bfe2e61 100644 --- a/tests/boundary/rust/rustBinaryImage.test.ts +++ b/tests/boundary/rust/rustBinaryImage.test.ts @@ -126,7 +126,7 @@ it("recognizes zero-virtual-size PE raw padding without accepting payload junk", bytes.writeUInt32LE(512, entry + 16); bytes.writeUInt32LE(512, 188); // Optional-header FileAlignment. expect(readRustBinaryImage(bytes).auditable_metadata).toMatchObject({ - section_bytes: payloadBytes, + section_bytes: 512, compressed_bytes: payloadBytes, packages, });