From 9cf49c7a784ace9b79e4b4c1022c9fbf2b3f77e1 Mon Sep 17 00:00:00 2001 From: jiangjiang248 Date: Tue, 13 Oct 2026 16:04:44 +0800 Subject: [PATCH] fix(snapshot): classify non-object roots as invalid content (#2685) Co-authored-by: jiangjiang248 <237556902+jiangjiang248@users.noreply.github.com> --- src/application/JsonInputFile.ts | 8 +- .../filesystem/analysisSnapshotInput.test.ts | 88 +++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 tests/boundary/filesystem/analysisSnapshotInput.test.ts diff --git a/src/application/JsonInputFile.ts b/src/application/JsonInputFile.ts index a226cb0cc..25e5fcec0 100644 --- a/src/application/JsonInputFile.ts +++ b/src/application/JsonInputFile.ts @@ -78,7 +78,9 @@ export const readJsonObjectFile = ( value === null || Array.isArray(value) ) - throw new TypeError("JSON document root must be an object"); + return err( + invalidJson(operation, "JSON document root must be an object"), + ); for (const members of scans) for (const [key, member] of members) { if (!Object.hasOwn(value, key)) continue; @@ -177,7 +179,9 @@ export const readJsonObjectFile = ( signal === undefined ? {} : { signal }, ); if (!rootObject) - throw new TypeError("JSON document root must be an object"); + return err( + invalidJson(operation, "JSON document root must be an object"), + ); const digest = hash.digest("hex"); if (previousDigest !== undefined && digest !== previousDigest) throw new RegularFileChangedError(path); diff --git a/tests/boundary/filesystem/analysisSnapshotInput.test.ts b/tests/boundary/filesystem/analysisSnapshotInput.test.ts new file mode 100644 index 000000000..85bd7b7d1 --- /dev/null +++ b/tests/boundary/filesystem/analysisSnapshotInput.test.ts @@ -0,0 +1,88 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { readAnalysisSnapshot } from "../../../src/application/binary/AnalysisSnapshotFiles.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { + snapshotBinding, + snapshotTarget, +} from "../../../src/domain/analysisSnapshot.js"; +import { + ANALYSIS_SNAPSHOT_PROFILE, + ANALYSIS_SNAPSHOT_TARGET, +} from "../../../src/domain/analysisSnapshot.fixture.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +describe.each([ + { mode: "native", paddingBytes: 0 }, + { mode: "streamed", paddingBytes: 9 * 1024 * 1024 }, +])("analysis snapshot input ($mode)", ({ paddingBytes }) => { + it.each(["[]", "null", "5", '"x"', "true"])( + "classifies a readable %s root as invalid content, not an I/O failure", + async (document) => { + const root = await createTestTempDirectory("rea-snapshot-root-"); + const path = join(root, "snapshot.json"); + const encoded = `${" ".repeat(paddingBytes)}${document}`; + await writeFile(path, encoded); + + const loaded = await readAnalysisSnapshot(path); + if (loaded.ok) throw new Error("Expected a non-object root rejection"); + const projected = projectAnalysisError(loaded.error); + expect(projected).toMatchObject({ + code: "evidence_integrity_mismatch", + details: { reason: "invalid-json" }, + }); + expect(projected.message).toContain("Repair or recreate"); + expect(loaded.error.cause).toMatchObject({ + message: "JSON document root must be an object", + }); + expect(await readFile(path, "utf8")).toBe(encoded); + }, + ); + + it("retains syntax and snapshot-member validation and loads valid objects", async () => { + const root = await createTestTempDirectory("rea-snapshot-validation-"); + const path = join(root, "snapshot.json"); + const prefix = " ".repeat(paddingBytes); + await writeFile(path, `${prefix}[`); + expect(await readAnalysisSnapshot(path)).toMatchObject({ + ok: false, + error: { _tag: "EvidenceFileError", reason: "invalid-json" }, + }); + await writeFile(path, `${prefix}{}`); + expect(await readAnalysisSnapshot(path)).toMatchObject({ + ok: false, + error: { _tag: "EvidenceIntegrityError" }, + }); + const snapshot = { + target: snapshotTarget(ANALYSIS_SNAPSHOT_TARGET), + binding: snapshotBinding(ANALYSIS_SNAPSHOT_PROFILE), + entries: [], + workflow_entries: [], + evidence_bundle: createEvidenceBundle([]), + }; + await writeFile(path, `${prefix}${JSON.stringify(snapshot)}`); + expect(await readAnalysisSnapshot(path)).toEqual({ + ok: true, + value: snapshot, + }); + }); +}); + +it("keeps file-selection failures distinct from invalid snapshot contents", async () => { + const root = await createTestTempDirectory("rea-snapshot-selection-"); + for (const [path, reason] of [ + [join(root, "missing.json"), "missing"], + [root, "not-file"], + ] as const) { + const loaded = await readAnalysisSnapshot(path); + if (loaded.ok) throw new Error("Expected a file-selection failure"); + expect(projectAnalysisError(loaded.error)).toMatchObject({ + code: "invalid_request", + details: { reason }, + }); + } +});