From 9c4dd7eb23110ffec2243e949f93db13f5c51605 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:49:19 +0800 Subject: [PATCH] fix(rust): preserve historical cargo-auditable feature facts Accept observed feature arrays, preserve omitted values as unknown across producer revisions, and keep older retained reports compatible. Reuse bounded JSON sizing before graph expansion. Fixes #6622 --- docs/rust-binaries.md | 12 +- .../rust/RustBinaryService.test.ts | 1 + .../analysisView/rustBinaryView.test.ts | 27 ++++ src/domain/analysisView/rustBinaryView.ts | 3 + src/domain/rust/rustBinary.ts | 2 + src/domain/rust/rustBinaryQuery.ts | 8 +- src/rust/RustAuditableReader.test.ts | 52 +++++++ src/rust/RustAuditableReader.ts | 42 +++++- tests/acceptance/rustBinaryCli.test.ts | 139 ++++++++++++++++++ tests/acceptance/rustDependencyViews.test.ts | 11 +- tests/boundary/rust/rustBinaryImage.test.ts | 4 +- 11 files changed, 288 insertions(+), 13 deletions(-) diff --git a/docs/rust-binaries.md b/docs/rust-binaries.md index 76f823198f..e52ba22a47 100644 --- a/docs/rust-binaries.md +++ b/docs/rust-binaries.md @@ -91,14 +91,22 @@ section is present, `auditable_metadata` contains: - `compressed_bytes` 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`, and `root`; + `kind`, `dependencies`, `features`, and `root`; - `root_package_index`, the single package marked `root`, or `null` when none is marked; - `dependency_edges`, pairs of package indices in package-then-dependency order; - `cyclic_dependency_indices`, sorted unique indices that participate in a cycle. An omitted `kind` stays `null`. The embedded format treats that omission as runtime, and that default is not stored in the section. An omitted dependency -list is an empty array. An omitted `root` flag is false. +list is an empty array. An omitted `features` field is `null`, because it does +not establish whether features were empty or the producer did not record them; +an explicit empty array establishes an empty list. An omitted `root` flag is +false. +Historical [cargo-auditable 0.4 package records](https://github.com/rust-secure-code/cargo-auditable/blob/v0.4.0/auditable-serde/src/lib.rs) +may include the package `features` list; REA preserves those strings. That +version omitted empty feature lists during serialization, while version 0.5 +removed the field entirely, as recorded in its +[changelog](https://github.com/rust-secure-code/cargo-auditable/blob/v0.5.0/CHANGELOG.md). `rustc_commit` is present only when a `/rustc/<40-hex>/` path is found in read-only data. Its `evidence_kind` is `inferred`. The hash is not a claim that diff --git a/src/application/rust/RustBinaryService.test.ts b/src/application/rust/RustBinaryService.test.ts index 0f85cf424b..28704c6503 100644 --- a/src/application/rust/RustBinaryService.test.ts +++ b/src/application/rust/RustBinaryService.test.ts @@ -34,6 +34,7 @@ const report: RustBinary = { source: "local", kind: null, dependencies: [], + features: null, root: true, }, ], diff --git a/src/domain/analysisView/rustBinaryView.test.ts b/src/domain/analysisView/rustBinaryView.test.ts index 074ac8dd2f..fc54b4a775 100644 --- a/src/domain/analysisView/rustBinaryView.test.ts +++ b/src/domain/analysisView/rustBinaryView.test.ts @@ -27,6 +27,7 @@ const report: RustBinary = { kind: null, root: true, dependencies: [0], + features: [], }, ], root_package_index: 0, @@ -76,6 +77,30 @@ it("revalidates mutable and merely frozen Rust imports despite a claimed seal", ).toBe(false); }); +it("pages retained Rust rows from before feature metadata was added as unknown", () => { + const previous = structuredClone(report); + const metadata = previous.auditable_metadata; + if (metadata === null) throw new Error("Expected Rust metadata"); + const legacy = { + ...previous, + auditable_metadata: { + ...metadata, + packages: metadata.packages.map(({ features: _features, ...pkg }) => pkg), + }, + } as unknown as RustBinary; + const result = projectAnalysisView(parent(legacy), { + kind: "rust", + collection: "packages", + fields: ["features"], + offset: 0, + limit: 10, + }); + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + page: { rows: [{ index: 0, features: null }] }, + }); +}); + it("checks exact artifact identity and subject architecture for every Rust view", () => { for (const changed of [ { local_path: "/different" }, @@ -121,6 +146,7 @@ it("bounds inherited warnings and retains cycle counts without walking a cyclic it("pages matching original indices and omits dependency vectors unless selected", () => { const value = structuredClone(report); + expect(value.auditable_metadata?.packages[0]?.features).toEqual([]); value.auditable_metadata!.packages.push({ name: "other", version: "1", @@ -128,6 +154,7 @@ it("pages matching original indices and omits dependency vectors unless selected kind: null, root: false, dependencies: [], + features: [], }); const source = parent(value); const first = projectAnalysisView(source, { diff --git a/src/domain/analysisView/rustBinaryView.ts b/src/domain/analysisView/rustBinaryView.ts index 2c4c1ac9f0..cb2f78b664 100644 --- a/src/domain/analysisView/rustBinaryView.ts +++ b/src/domain/analysisView/rustBinaryView.ts @@ -229,6 +229,9 @@ export const projectRustBinaryView = ( ...(fields?.has("dependencies") === true && { dependencies: row.dependencies, }), + ...(fields?.has("features") === true && { + features: row.features, + }), }), ); return ok({ diff --git a/src/domain/rust/rustBinary.ts b/src/domain/rust/rustBinary.ts index 5ba3cdfa57..e3d484c994 100644 --- a/src/domain/rust/rustBinary.ts +++ b/src/domain/rust/rustBinary.ts @@ -31,6 +31,7 @@ export const RUST_AUDITABLE_FORMAT_VERSIONS = [0, 1, 8] as const; const index = z.int().nonnegative(); const rustcHash = z.string().regex(/^[0-9a-f]{40}$/u); +export const rustPackageFeaturesSchema = z.array(z.string().min(1)).nullable(); /** One embedded package, with omitted kind and root preserved as null and false. */ export const rustPackageSchema = z.strictObject({ @@ -39,6 +40,7 @@ export const rustPackageSchema = z.strictObject({ source: z.string().min(1), kind: z.enum(["runtime", "build"]).nullable(), dependencies: z.array(index), + features: rustPackageFeaturesSchema.default(null), root: z.boolean(), }); diff --git a/src/domain/rust/rustBinaryQuery.ts b/src/domain/rust/rustBinaryQuery.ts index e4a31376ca..d414eaac7e 100644 --- a/src/domain/rust/rustBinaryQuery.ts +++ b/src/domain/rust/rustBinaryQuery.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { inspectRustBinaryInputSchema, rustPackageSchema, + rustPackageFeaturesSchema, rustAuditableMetadataSchema, } from "./rustBinary.js"; @@ -44,7 +45,12 @@ export const rustBinaryViewRequestSchema = z.discriminatedUnion("collection", [ export const rustBinaryViewPageSchema = z.discriminatedUnion("collection", [ z.strictObject({ collection: z.literal("packages"), - rows: z.array(rustPackageSchema.partial().extend({ index })), + rows: z.array( + rustPackageSchema.partial().extend({ + features: rustPackageFeaturesSchema.optional(), + index, + }), + ), }), z.strictObject({ collection: z.literal("edges"), diff --git a/src/rust/RustAuditableReader.test.ts b/src/rust/RustAuditableReader.test.ts index 3edfdf2890..52590f673d 100644 --- a/src/rust/RustAuditableReader.test.ts +++ b/src/rust/RustAuditableReader.test.ts @@ -58,6 +58,58 @@ it("preserves package order, omitted kind, and the root index", () => { expect(parsed.uncompressed_bytes).toBeGreaterThan(parsed.compressed_bytes); }); +it("preserves historical cargo-auditable feature lists and rejects bad values", () => { + const parsed = section({ + format: 0, + packages: [ + { + name: "feature_dep", + version: "1.2.3", + source: "crates.io", + features: ["default", "serde"], + }, + ], + }); + expect(parsed.packages).toEqual([ + { + name: "feature_dep", + version: "1.2.3", + source: "crates.io", + kind: null, + dependencies: [], + features: ["default", "serde"], + root: false, + }, + ]); + const omitted = section({ + packages: [{ name: "no_features", version: "1", source: "crates.io" }], + }); + const explicitEmpty = section({ + packages: [ + { + name: "no_features", + version: "1", + source: "crates.io", + features: [], + }, + ], + }); + expect(omitted.packages[0]?.features).toBeNull(); + expect(explicitEmpty.packages[0]?.features).toEqual([]); + expect(() => + section({ + packages: [ + { + name: "feature_dep", + version: "1.2.3", + source: "crates.io", + features: ["default", 7], + }, + ], + }), + ).toThrow(RustAuditableFormatFailure); +}); + it("reports dependency cycles and keeps an omitted format version unknown", () => { const parsed = section({ packages: [ diff --git a/src/rust/RustAuditableReader.ts b/src/rust/RustAuditableReader.ts index d2f561ecf2..b8e54dfa03 100644 --- a/src/rust/RustAuditableReader.ts +++ b/src/rust/RustAuditableReader.ts @@ -5,6 +5,7 @@ import { type RustAuditableMetadata, type RustPackage, } from "../domain/rust/rustBinary.js"; +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; @@ -24,6 +25,7 @@ const packageSchema = z.strictObject({ source: z.string().min(1), kind: z.enum(["runtime", "build"]).optional(), dependencies: z.array(z.int().nonnegative()).optional(), + features: z.array(z.string().min(1)).optional(), root: z.boolean().optional(), }); const documentVersionSchema = z.object({ @@ -259,6 +261,7 @@ const MIN_PACKAGE_OUTPUT_BYTES = JSON.stringify({ source: "a", kind: null, dependencies: [], + features: null, root: true, }).length; const MIN_EDGE_OUTPUT_BYTES = JSON.stringify({ @@ -289,9 +292,27 @@ const admitGraphExpansion = (value: unknown): void => { "Dependency graph exceeds the complete report byte budget before expansion", ); }; + const currentEdgeBytes = () => + edges * MIN_EDGE_OUTPUT_BYTES + Math.max(0, edges - 1); admit(); for (const pkg of packages) { // Shape and index validity remain owned by documentSchema/rootIndex. + if ( + typeof pkg === "object" && + pkg !== null && + "features" in pkg && + Array.isArray(pkg.features) && + pkg.features.every((feature) => typeof feature === "string") + ) { + const featureBudget = + RUST_OUTPUT_BYTES - minimumBytes - currentEdgeBytes() + 4; + const featureBytes = jsonValueByteLength( + pkg.features as string[], + featureBudget, + ); + minimumBytes += featureBytes - 4; + admit(); + } if ( typeof pkg !== "object" || pkg === null || @@ -312,6 +333,7 @@ const PACKAGE_FIELDS = new Set([ "source", "kind", "dependencies", + "features", "root", ]); @@ -336,6 +358,7 @@ const packageRecord = ( pkg: unknown, ): pkg is { readonly dependencies?: readonly unknown[]; + readonly features?: readonly unknown[]; } => { if (typeof pkg !== "object" || pkg === null || Array.isArray(pkg)) return false; @@ -358,13 +381,19 @@ const packageRecord = ( if (record.root !== undefined && typeof record.root !== "boolean") return false; const dependencies = record.dependencies; - if (dependencies === undefined) return true; + if ( + dependencies !== undefined && + (!Array.isArray(dependencies) || + !dependencies.every( + (entry) => + typeof entry === "number" && Number.isInteger(entry) && entry >= 0, + )) + ) + return false; + const features = record.features; return ( - Array.isArray(dependencies) && - dependencies.every( - (entry) => - typeof entry === "number" && Number.isInteger(entry) && entry >= 0, - ) + features === undefined || + (Array.isArray(features) && features.every(nonEmptyString)) ); }; @@ -406,6 +435,7 @@ const normalizePackages = ( source: pkg.source, kind: pkg.kind ?? null, dependencies: pkg.dependencies ?? [], + features: pkg.features ?? null, root: pkg.root ?? false, })); diff --git a/tests/acceptance/rustBinaryCli.test.ts b/tests/acceptance/rustBinaryCli.test.ts index eb8e1c07de..e30e1c8961 100644 --- a/tests/acceptance/rustBinaryCli.test.ts +++ b/tests/acceptance/rustBinaryCli.test.ts @@ -101,6 +101,145 @@ cliTest( }, ); +cliTest( + "preserves historical cargo-auditable features through CLI and stdio MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-rust-features-legacy-"); + const { client, call } = await connectLocalToolsMcp(); + const validCases = [ + { + name: "features", + document: { + packages: [ + { + name: "legacy_dep", + version: "1.2.3", + source: "crates.io", + features: ["default", "serde"], + }, + { + name: "app", + version: "0.1.0", + source: "local", + dependencies: [0], + root: true, + }, + ], + }, + features: ["default", "serde"], + }, + { + name: "omitted-features-unknown", + document: { + packages: [ + { + name: "legacy_dep", + version: "1.2.3", + source: "crates.io", + }, + ], + }, + features: null, + }, + { + name: "explicit-empty-features", + document: { + packages: [ + { + name: "legacy_dep", + version: "1.2.3", + source: "crates.io", + features: [], + }, + ], + }, + features: [], + }, + ]; + const malformedPath = join(root, "invalid-features.elf"); + await writeFile( + malformedPath, + createRustImage({ + format: "elf", + payload: rustDependencyPayload({ + packages: [ + { + name: "legacy_dep", + version: "1.2.3", + source: "crates.io", + features: ["default", 7], + }, + ], + }), + }), + ); + + for (const surface of ["cli", "mcp"] as const) { + for (const fixture of validCases) { + const path = join(root, `${fixture.name}.elf`); + await writeFile( + path, + createRustImage({ + format: "elf", + payload: rustDependencyPayload(fixture.document), + }), + ); + if (surface === "cli") { + const response = await cli.run({ + arguments: ["inspect-rust-binary", path, "--json"], + }); + expect(response.exitCode, response.stderr).toBe(0); + const evidence = toolContract( + "inspect_rust_binary", + ).outputSchema.parse(response.json); + expect( + rustBinarySchema.parse(evidence.normalized_result) + .auditable_metadata?.packages[0]?.features, + ).toEqual(fixture.features); + } else { + const response = await call("inspect_rust_binary", { path }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const evidence = toolContract( + "inspect_rust_binary", + ).outputSchema.parse(response.structuredContent); + expect( + rustBinarySchema.parse(evidence.normalized_result) + .auditable_metadata?.packages[0]?.features, + ).toEqual(fixture.features); + } + } + + if (surface === "cli") { + const response = await cli.run({ + arguments: ["inspect-rust-binary", malformedPath, "--json"], + }); + expect(response.exitCode).toBe(1); + expect(response.json).toMatchObject({ + code: "invalid_request", + details: { + operation: "inspect_rust_binary", + issues: [{ path: ["path"], reason: "invalid_format" }], + }, + }); + } else { + const response = await call("inspect_rust_binary", { + path: malformedPath, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "invalid_request", + details: { + operation: "inspect_rust_binary", + issues: [{ path: ["path"], reason: "invalid_format" }], + }, + }); + } + } + await client.ping(); + }, + 30000, +); + cliTest("returns null metadata when .dep-v0 is absent", async ({ cli }) => { const root = await createTestTempDirectory("rea-rust-cli-missing-"); const path = join(root, "plain"); diff --git a/tests/acceptance/rustDependencyViews.test.ts b/tests/acceptance/rustDependencyViews.test.ts index 6f97f45a24..5f29a90c81 100644 --- a/tests/acceptance/rustDependencyViews.test.ts +++ b/tests/acceptance/rustDependencyViews.test.ts @@ -123,6 +123,7 @@ cliTest( version: "1", source: "crates.io", dependencies: [2], + features: ["default"], }, { name: "same", @@ -153,7 +154,7 @@ cliTest( const selection = { collection: "packages", names: ["same"], - fields: ["name", "version", "dependencies"], + fields: ["name", "version", "dependencies", "features"], }; const direct = await call("inspect_rust_binary", { path, @@ -169,7 +170,13 @@ cliTest( page: { collection: "packages", rows: [ - { index: 0, name: "same", version: "1", dependencies: [2] }, + { + index: 0, + name: "same", + version: "1", + dependencies: [2], + features: ["default"], + }, { index: 1, name: "same", version: "2", dependencies: [0, 2] }, ], }, diff --git a/tests/boundary/rust/rustBinaryImage.test.ts b/tests/boundary/rust/rustBinaryImage.test.ts index 718237cbf4..de26cacace 100644 --- a/tests/boundary/rust/rustBinaryImage.test.ts +++ b/tests/boundary/rust/rustBinaryImage.test.ts @@ -19,7 +19,7 @@ const packages = [ source: "crates.io", kind: null, dependencies: [], - features: [], + features: null, root: false, }, { @@ -28,7 +28,7 @@ const packages = [ source: "local", kind: null, dependencies: [0], - features: [], + features: null, root: true, }, ];