From 0747e46401a4f5e487302dcc5061548b4634db5c Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:55:57 +0800 Subject: [PATCH] fix(cli): recognize integrity failures with partial observations (#7836) --- docs/error-contract.schema.json | 4 +- src/contracts/errorSchemas.ts | 36 +++++++------- tests/acceptance/webModuleCli.test.ts | 47 +++++++++++++++++++ .../contracts/analysisErrorProjection.test.ts | 36 +++++++++++++- 4 files changed, 104 insertions(+), 19 deletions(-) diff --git a/docs/error-contract.schema.json b/docs/error-contract.schema.json index 07a1207cdc..a5b4ccf324 100644 --- a/docs/error-contract.schema.json +++ b/docs/error-contract.schema.json @@ -701,7 +701,7 @@ "calculated_sha256", "unpacked" ], - "additionalProperties": false + "additionalProperties": {} }, { "type": "object", @@ -727,7 +727,7 @@ "calculated_md5", "unpacked" ], - "additionalProperties": false + "additionalProperties": {} } ] } diff --git a/src/contracts/errorSchemas.ts b/src/contracts/errorSchemas.ts index 05768e1931..6e3839fcc7 100644 --- a/src/contracts/errorSchemas.ts +++ b/src/contracts/errorSchemas.ts @@ -51,22 +51,26 @@ export const analysisErrorProjectionSchema = z.discriminatedUnion("code", [ code: z.literal("artifact_integrity_mismatch"), ...common, details: z.union([ - z.strictObject({ - logical_path: z.string(), - declared_sha256: z.string().nullable(), - calculated_sha256: z.string().nullable(), - unpacked: z.boolean(), - }), - z.strictObject({ - logical_path: z.string(), - algorithm: z.literal("md5"), - declared_md5: z.string().regex(/^[a-f0-9]{32}$/u), - calculated_md5: z - .string() - .regex(/^[a-f0-9]{32}$/u) - .nullable(), - unpacked: z.boolean(), - }), + z + .object({ + logical_path: z.string(), + declared_sha256: z.string().nullable(), + calculated_sha256: z.string().nullable(), + unpacked: z.boolean(), + }) + .catchall(jsonValueSchema), + z + .object({ + logical_path: z.string(), + algorithm: z.literal("md5"), + declared_md5: z.string().regex(/^[a-f0-9]{32}$/u), + calculated_md5: z + .string() + .regex(/^[a-f0-9]{32}$/u) + .nullable(), + unpacked: z.boolean(), + }) + .catchall(jsonValueSchema), ]), }) .strict(), diff --git a/tests/acceptance/webModuleCli.test.ts b/tests/acceptance/webModuleCli.test.ts index 1cfe3a1cb9..57509e6770 100644 --- a/tests/acceptance/webModuleCli.test.ts +++ b/tests/acceptance/webModuleCli.test.ts @@ -89,3 +89,50 @@ cliTest( expect(response.structuredContent).toEqual(expected); }, ); + +cliTest( + "returns a failing outcome when the selected source digest disagrees", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-module-integrity-"); + const artifacts = webModuleArtifactsFixture("export const a = 1;"); + const files = join(root, "files"); + await mkdir(join(files, "modules"), { recursive: true }); + await writeFile(join(files, "modules", "main.js"), "export const a = 2;"); + const manifest = join(root, "manifest.json"); + await writeFile( + manifest, + JSON.stringify({ + ...artifacts.manifest, + output_directory: root, + analysis_input: { input_path: files, format: "directory" }, + }), + ); + for (const full of [false, true]) { + const result = await cli.run({ + arguments: [ + "trace-web-module-imports", + manifest, + "0", + "--json", + ...(full ? ["--full-output"] : []), + ], + }); + expect(result.exitCode, result.stdout + result.stderr).toBe(1); + const failure = { + code: "artifact_integrity_mismatch", + details: { + partial_observation: { + kind: "web-artifact-acquisition", + analysis: "not-performed", + }, + }, + }; + if (full) + expect(result.json).toMatchObject({ + ok: false, + error: failure, + }); + else expect(result.json).toMatchObject(failure); + } + }, +); diff --git a/tests/conformance/contracts/analysisErrorProjection.test.ts b/tests/conformance/contracts/analysisErrorProjection.test.ts index 0c2e5574fc..c64dd6bdb3 100644 --- a/tests/conformance/contracts/analysisErrorProjection.test.ts +++ b/tests/conformance/contracts/analysisErrorProjection.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; +import { Ajv2020 } from "ajv/dist/2020.js"; import { ProcessCaptureError } from "../../../src/process/capture/ProcessCaptureError.js"; -import { analysisErrorProjectionSchema } from "../../../src/contracts/errorSchemas.js"; +import { + analysisErrorJsonSchema, + analysisErrorProjectionSchema, +} from "../../../src/contracts/errorSchemas.js"; import { ArtifactOperationError } from "../../../src/domain/artifactOperationError.js"; import { EvidenceFileError } from "../../../src/domain/evidenceErrors.js"; import { HopperRemoteError } from "../../../src/domain/hopperErrors.js"; @@ -14,6 +18,36 @@ import { } from "../../../src/domain/analysisErrorCore.js"; import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; +it.each([ + { declaredSha256: "a".repeat(64), calculatedSha256: "b".repeat(64) }, + { + algorithm: "md5" as const, + declaredMd5: "a".repeat(32), + calculatedMd5: "b".repeat(32), + }, +])("retains partial observations on integrity errors", (digests) => { + const error = new ArtifactOperationError( + "trace_web_module_imports", + "integrity", + { + logicalPath: "modules/main.js", + unpacked: false, + ...digests, + }, + ); + error.retainPartialObservation({ + kind: "web-artifact-acquisition", + analysis: "not-performed", + files: [], + }); + const projected = projectAnalysisError(error); + expect(analysisErrorProjectionSchema.parse(projected)).toEqual(projected); + const validate = new Ajv2020({ strict: false }).compile( + analysisErrorJsonSchema, + ); + expect(validate(projected), JSON.stringify(validate.errors)).toBe(true); +}); + describe("analysis error projection contract", () => { it("keeps cleanup failure distinct from unreadable collected output", () => { const projected = projectAnalysisError(