diff --git a/packages/shared/src/schemaJson.test.ts b/packages/shared/src/schemaJson.test.ts index 1cc6e38d919..c808a9b7c51 100644 --- a/packages/shared/src/schemaJson.test.ts +++ b/packages/shared/src/schemaJson.test.ts @@ -1,7 +1,15 @@ +import * as Cause from "effect/Cause"; +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { extractJsonObject, fromLenientJson } from "./schemaJson.ts"; +import { + decodeJsonResult, + extractJsonObject, + formatSchemaError, + fromLenientJson, +} from "./schemaJson.ts"; const decodeLenientJson = Schema.decodeUnknownSync(fromLenientJson(Schema.Unknown)); @@ -48,4 +56,90 @@ Done.`), it("rejects malformed JSON after lenient preprocessing", () => { expect(() => decodeLenientJson('{ "enabled": true,, }')).toThrow(); }); + + it("formats schema failures with paths without exposing invalid values", () => { + const decodeCredential = decodeJsonResult(Schema.Struct({ token: Schema.Number })); + const decoded = decodeCredential('{"token":"credential=secret-value"}'); + + expect(Result.isFailure(decoded)).toBe(true); + if (Result.isFailure(decoded)) { + expect(formatSchemaError(decoded.failure)).toBe('Invalid type\n at ["token"]'); + } + }); + + it("preserves nested paths reported by schema filters", () => { + const decode = decodeJsonResult( + Schema.String.check( + Schema.makeFilter(() => ({ + path: ["session", "token"], + issue: "credential is invalid", + })), + ), + ); + const decoded = decode('"credential=secret-value"'); + + expect(Result.isFailure(decoded)).toBe(true); + if (Result.isFailure(decoded)) { + const diagnostic = formatSchemaError(decoded.failure); + expect(diagnostic).toBe('Invalid value\n at ["session"]["token"]'); + expect(diagnostic).not.toContain("credential=secret-value"); + } + }); + + it("does not expose malformed lenient JSON input in diagnostics", () => { + const decode = Schema.decodeUnknownExit(fromLenientJson(Schema.Unknown)); + const exit = decode('{"token":"credential=secret-value",,}'); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const diagnostic = formatSchemaError(exit.cause); + expect(diagnostic).toBe("Invalid value"); + expect(diagnostic).not.toContain("credential=secret-value"); + } + }); + + it("summarizes unexpected defects without serializing their messages", () => { + const diagnostic = formatSchemaError(Cause.die(new Error("credential=secret-value"))); + + expect(diagnostic).toBe( + "Schema validation failed (failureCount=0, defectCount=1, interruptionCount=0).", + ); + }); + + it("bounds the number of formatted schema issues", () => { + const decode = decodeJsonResult(Schema.Struct({ token: Schema.Number })); + const failures: Array> = []; + for (let index = 0; index < 10; index += 1) { + const decoded = decode(`{"token":"credential=secret-value-${index}"}`); + if (Result.isFailure(decoded)) { + failures.push(decoded.failure); + } + } + + const cause = Cause.fromReasons(failures.flatMap((cause) => cause.reasons)); + const diagnostic = formatSchemaError(cause); + expect(diagnostic.match(/Invalid type/g)).toHaveLength(8); + expect(diagnostic).toContain("... and 2 more issue(s)"); + }); + + it("retains the omitted issue count when bounding long diagnostics", () => { + const longPath = Array.from({ length: 16 }, (_, index) => `${index}-${"segment".repeat(16)}`); + const decode = decodeJsonResult( + Schema.String.check( + Schema.makeFilter(() => ({ path: longPath, issue: "credential is invalid" })), + ), + ); + const failures: Array> = []; + for (let index = 0; index < 10; index += 1) { + const decoded = decode(`"credential=secret-value-${index}"`); + if (Result.isFailure(decoded)) { + failures.push(decoded.failure); + } + } + + const cause = Cause.fromReasons(failures.flatMap((cause) => cause.reasons)); + const diagnostic = formatSchemaError(cause); + expect(diagnostic.length).toBeLessThanOrEqual(2_048); + expect(diagnostic.endsWith("\n... and 2 more issue(s)")).toBe(true); + }); }); diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index 8b76d9e0a2d..04d26d9c229 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -8,6 +8,104 @@ import * as SchemaGetter from "effect/SchemaGetter"; import * as SchemaIssue from "effect/SchemaIssue"; import * as SchemaTransformation from "effect/SchemaTransformation"; +const MAX_SCHEMA_DIAGNOSTIC_ISSUES = 8; +const MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENTS = 16; +const MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENT_LENGTH = 64; +const MAX_SCHEMA_DIAGNOSTIC_LENGTH = 2_048; + +interface SchemaDiagnosticIssue { + readonly message: string; + readonly path: ReadonlyArray; +} + +// Schema's default formatter includes actual values. These diagnostics cross +// process and UI boundaries, so retain only issue kinds and bounded paths. + +function truncateDiagnostic(value: string, maxLength: number): string { + return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`; +} + +function formatDiagnosticPathSegment(key: PropertyKey): string { + if (typeof key === "number") { + return `[${key}]`; + } + const value = truncateDiagnostic( + typeof key === "symbol" ? String(key) : key, + MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENT_LENGTH, + ); + return `[${JSON.stringify(value)}]`; +} + +function formatDiagnosticIssue(issue: SchemaDiagnosticIssue): string { + if (issue.path.length === 0) { + return issue.message; + } + const path = issue.path + .slice(0, MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENTS) + .map(formatDiagnosticPathSegment) + .join(""); + const suffix = issue.path.length > MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENTS ? "[...]" : ""; + return `${issue.message}\n at ${path}${suffix}`; +} + +function schemaDiagnosticMessage(issue: SchemaIssue.Issue): string { + switch (issue._tag) { + case "InvalidType": + return "Invalid type"; + case "InvalidValue": + case "Filter": + case "AnyOf": + case "Encoding": + case "Pointer": + case "Composite": + return "Invalid value"; + case "MissingKey": + return "Missing key"; + case "UnexpectedKey": + return "Unexpected key"; + case "Forbidden": + return "Forbidden operation"; + case "OneOf": + return "Expected exactly one schema member to match"; + } +} + +function collectSchemaDiagnosticIssues( + issue: SchemaIssue.Issue, + path: ReadonlyArray, + diagnostics: Array, +): number { + switch (issue._tag) { + case "Encoding": + return collectSchemaDiagnosticIssues(issue.issue, path, diagnostics); + case "Filter": + if (issue.issue._tag !== "InvalidValue") { + return collectSchemaDiagnosticIssues(issue.issue, path, diagnostics); + } + break; + case "Pointer": + return collectSchemaDiagnosticIssues(issue.issue, [...path, ...issue.path], diagnostics); + case "Composite": + return issue.issues.reduce( + (count, issue) => count + collectSchemaDiagnosticIssues(issue, path, diagnostics), + 0, + ); + case "AnyOf": + if (issue.issues.length > 0) { + return issue.issues.reduce( + (count, issue) => count + collectSchemaDiagnosticIssues(issue, path, diagnostics), + 0, + ); + } + break; + } + + if (diagnostics.length < MAX_SCHEMA_DIAGNOSTIC_ISSUES) { + diagnostics.push({ message: schemaDiagnosticMessage(issue), path }); + } + return 1; +} + export const decodeJsonResult = >( schema: S, ) => { @@ -35,10 +133,40 @@ export const decodeUnknownJsonResult = ) => { - const squashed = Cause.squash(cause); - return Schema.isSchemaError(squashed) - ? SchemaIssue.makeFormatterDefault()(squashed.issue) - : Cause.pretty(cause); + const issues: Array = []; + let issueCount = 0; + let failureCount = 0; + let defectCount = 0; + let interruptionCount = 0; + + for (const reason of cause.reasons) { + switch (reason._tag) { + case "Fail": + failureCount += 1; + if (Schema.isSchemaError(reason.error)) { + issueCount += collectSchemaDiagnosticIssues(reason.error.issue, [], issues); + } + break; + case "Die": + defectCount += 1; + break; + case "Interrupt": + interruptionCount += 1; + break; + } + } + + if (issues.length === 0) { + return `Schema validation failed (failureCount=${failureCount}, defectCount=${defectCount}, interruptionCount=${interruptionCount}).`; + } + + const omittedIssueCount = issueCount - issues.length; + const formatted = issues.map(formatDiagnosticIssue).join("\n"); + if (omittedIssueCount === 0) { + return truncateDiagnostic(formatted, MAX_SCHEMA_DIAGNOSTIC_LENGTH); + } + const suffix = `\n... and ${omittedIssueCount} more issue(s)`; + return truncateDiagnostic(formatted, MAX_SCHEMA_DIAGNOSTIC_LENGTH - suffix.length) + suffix; }; /** @@ -67,9 +195,7 @@ const parseLenientJsonGetter = SchemaGetter.onSome((input: string) => { return decodeJsonString(stripped).pipe( Effect.map(Option.some), - Effect.mapError( - (error) => new SchemaIssue.InvalidValue(Option.some(input), { message: String(error) }), - ), + Effect.mapError((error) => error.issue), ); });