From 42506ffc6acd47028d24e8f288b52b701ada1866 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 30 Jul 2026 11:35:52 +1200 Subject: [PATCH 1/4] Harden imported JSON Schema patterns --- .changeset/safe-json-schema-patterns.md | 5 + packages/effect/SCHEMA.md | 5 + packages/effect/src/SchemaRepresentation.ts | 22 +++- .../internal/schema/fromJsonSchemaDocument.ts | 124 +++++++++++++++++- .../fromJsonSchemaDocument.test.ts | 37 +++++- 5 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 .changeset/safe-json-schema-patterns.md diff --git a/.changeset/safe-json-schema-patterns.md b/.changeset/safe-json-schema-patterns.md new file mode 100644 index 00000000000..782b0c84450 --- /dev/null +++ b/.changeset/safe-json-schema-patterns.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Reject potentially unsafe regular expression patterns when importing JSON Schema documents by default, with an explicit opt-out for trusted documents. diff --git a/packages/effect/SCHEMA.md b/packages/effect/SCHEMA.md index 1e437f1d2dd..cd898abbd4b 100644 --- a/packages/effect/SCHEMA.md +++ b/packages/effect/SCHEMA.md @@ -6437,6 +6437,11 @@ Import is best-effort: JSON Schema constructs are translated to Effect schemas w lossless reconstruction of an original Effect schema. The optional `onEnter` callback can normalize each JSON Schema node before it is translated. +Imported `pattern` and `patternProperties` values are executed by the runtime's native regular expression engine during +validation. The importer rejects patterns with potentially unsafe nested unbounded repetition by default. Set +`unsafeAllowComplexPatterns: true` only when importing trusted documents and accepting the risk of validation blocking for +an unbounded amount of time. + ## Code generation ### `toCodeDocument` diff --git a/packages/effect/src/SchemaRepresentation.ts b/packages/effect/src/SchemaRepresentation.ts index 7796ec4c50b..b125786e546 100644 --- a/packages/effect/src/SchemaRepresentation.ts +++ b/packages/effect/src/SchemaRepresentation.ts @@ -615,13 +615,23 @@ export const makeFilterGroupReviver:

( * * **Gotchas** * - * `onEnter` must return a JSON Schema object. Its result is used directly, and exceptions raised by the callback pass through unchanged. + * Patterns with potentially unsafe nested unbounded repetition are rejected by default. Set `unsafeAllowComplexPatterns` + * to `true` only when imported documents are trusted; such patterns use the runtime's native regular expression engine + * and may block validation for an unbounded amount of time. + * + * `onEnter` must return a JSON Schema object. Its result is used directly, and exceptions raised by the callback pass + * through unchanged. * * @category models * @since 4.0.0 */ export interface FromJsonSchemaOptions { readonly onEnter?: ((schema: JsonSchema.JsonSchema) => JsonSchema.JsonSchema) | undefined + /** + * Allows patterns with potentially unsafe nested unbounded repetition. Enable only for trusted documents. Defaults to + * `false`. + */ + readonly unsafeAllowComplexPatterns?: boolean | undefined } /** @@ -1166,7 +1176,10 @@ export function fromRepresentations( * * **Gotchas** * - * Import is best-effort. Built-in declarations and checks are reconstructed with importer-owned revivers. Callback results are used directly, and exceptions raised by a callback pass through unchanged. + * Import is best-effort. Built-in declarations and checks are reconstructed with importer-owned revivers. Patterns with + * potentially unsafe nested unbounded repetition are rejected by default because validation uses the runtime's native + * regular expression engine. Use `unsafeAllowComplexPatterns` only for trusted documents. Callback results are used + * directly, and exceptions raised by a callback pass through unchanged. * * @see {@link fromJsonSchemaMultiDocument} for multiple roots sharing definitions * @see {@link toRepresentation} for converting the result to a representation document @@ -1190,7 +1203,10 @@ export function fromJsonSchemaDocument( * * **Gotchas** * - * Every definition is translated, including definitions that no root references. Callback results are used directly, and exceptions raised by a callback pass through unchanged. + * Every definition is translated, including definitions that no root references. Patterns with potentially unsafe nested + * unbounded repetition are rejected by default because validation uses the runtime's native regular expression engine. + * Use `unsafeAllowComplexPatterns` only for trusted documents. Callback results are used directly, and exceptions raised + * by a callback pass through unchanged. * * @see {@link fromJsonSchemaDocument} for a single root * @see {@link fromSchemaMultiDocument} for converting the result to a representation document diff --git a/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts b/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts index 8301d808e72..4f082cc7774 100644 --- a/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts +++ b/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts @@ -124,6 +124,116 @@ function addNumberCheck( } } +// A mandatory atom separates repetitions. Without one, nested unbounded +// repetition can partition the same input exponentially many ways. +function hasNestedUnboundedRepetition(pattern: string): boolean { + interface Branch { + mandatoryAtoms: number + hasUnboundedRepetition: boolean + } + interface Group { + branches: Array + branch: Branch + lastAtomWasMandatory: boolean + } + function makeGroup(): Group { + return { + branches: [], + branch: { mandatoryAtoms: 0, hasUnboundedRepetition: false }, + lastAtomWasMandatory: false + } + } + function addAtom(group: Group, hasUnboundedRepetition = false): void { + group.branch.mandatoryAtoms++ + group.branch.hasUnboundedRepetition ||= hasUnboundedRepetition + group.lastAtomWasMandatory = true + } + + const groups = [makeGroup()] + let closedGroupIsUnsafeToRepeat = false + let previousWasClosedGroup = false + for (let index = 0; index < pattern.length; index++) { + const character = pattern[index] + if (character === "\\") { + addAtom(groups[groups.length - 1]) + index++ + previousWasClosedGroup = false + continue + } + if (character === "[") { + for (index++; index < pattern.length; index++) { + if (pattern[index] === "\\") index++ + else if (pattern[index] === "]") break + } + addAtom(groups[groups.length - 1]) + previousWasClosedGroup = false + continue + } + if (character === "(") { + groups.push(makeGroup()) + if (pattern[index + 1] === "?") { + if (pattern[index + 2] === "<" && pattern[index + 3] !== "=" && pattern[index + 3] !== "!") { + const end = pattern.indexOf(">", index + 3) + if (end !== -1) index = end + } else if (pattern[index + 2] === "<") { + index += 3 + } else { + index += 2 + } + } + previousWasClosedGroup = false + continue + } + if (character === ")" && groups.length > 1) { + const closed = groups.pop() as Group + closed.branches.push(closed.branch) + const hasUnboundedRepetition = closed.branches.some((branch) => branch.hasUnboundedRepetition) + closedGroupIsUnsafeToRepeat = closed.branches.some((branch) => + branch.hasUnboundedRepetition && branch.mandatoryAtoms <= 1 + ) + addAtom(groups[groups.length - 1], hasUnboundedRepetition) + previousWasClosedGroup = true + continue + } + const group = groups[groups.length - 1] + if (character === "|") { + group.branches.push(group.branch) + group.branch = { mandatoryAtoms: 0, hasUnboundedRepetition: false } + group.lastAtomWasMandatory = false + previousWasClosedGroup = false + continue + } + let isQuantifier = character === "*" || character === "+" || character === "?" + let isUnbounded = character === "*" || character === "+" + let minimum = character === "*" || character === "?" ? 0 : 1 + if (character === "{") { + const end = pattern.indexOf("}", index + 1) + if (end !== -1) { + const content = pattern.slice(index + 1, end) + const match = /^(\d+)(?:,(\d*))?$/.exec(content) + if (match !== null) { + isQuantifier = true + minimum = Number(match[1]) + isUnbounded = content.includes(",") && match[2] === "" + index = end + } + } + } + if (isQuantifier) { + if (isUnbounded && previousWasClosedGroup && closedGroupIsUnsafeToRepeat) return true + if (minimum === 0 && group.lastAtomWasMandatory) { + group.branch.mandatoryAtoms-- + group.lastAtomWasMandatory = false + } + group.branch.hasUnboundedRepetition ||= isUnbounded + } else if (character !== "^" && character !== "$") { + addAtom(group) + } + previousWasClosedGroup = false + } + return false +} + function jsonSchemaAnnotations( schema: JsonSchema.JsonSchema ): Schema.Annotations.Annotations | undefined { @@ -741,7 +851,7 @@ function translateJsonSchemaMultiDocument( case "string": return { _tag: "String", - checks: collectStringChecks(schema) + checks: collectStringChecks(schema, path) } case "number": case "integer": @@ -786,11 +896,14 @@ function translateJsonSchemaMultiDocument( } } - function collectStringChecks(schema: JsonSchema.JsonSchema): Array { + function collectStringChecks(schema: JsonSchema.JsonSchema, path: Path): Array { const checks: Array = [] addNumberCheck(checks, schema.minLength, "effect/schema/isMinLength", "minLength") addNumberCheck(checks, schema.maxLength, "effect/schema/isMaxLength", "maxLength") if (typeof schema.pattern === "string") { + if (options?.unsafeAllowComplexPatterns !== true && hasNestedUnboundedRepetition(schema.pattern)) { + throw errorWithPath("Potentially unsafe pattern with nested unbounded repetition", [...path, "pattern"]) + } checks.push(jsonSchemaFilter("effect/schema/isPattern", { source: schema.pattern, flags: "" })) } return checks @@ -849,6 +962,13 @@ function translateJsonSchemaMultiDocument( !Array.isArray(schema.patternProperties) ) { for (const [pattern, value] of Object.entries(schema.patternProperties)) { + if (options?.unsafeAllowComplexPatterns !== true && hasNestedUnboundedRepetition(pattern)) { + throw errorWithPath("Potentially unsafe pattern with nested unbounded repetition", [ + ...path, + "patternProperties", + pattern + ]) + } signatures.push({ parameter: { _tag: "String", diff --git a/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts b/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts index f272dd61306..9be6b7fc2c7 100644 --- a/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts +++ b/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts @@ -844,7 +844,7 @@ describe("fromJsonSchemaDocument", () => { ) }) - it("pattern", () => { + it("round-trips ordinary patterns", () => { assertFromJsonSchema( { schema: { type: "string", pattern: "a*" } }, { @@ -910,6 +910,27 @@ describe("fromJsonSchemaDocument", () => { } ) }) + + it("rejects patterns with nested unbounded repetition", () => { + throws( + () => + SchemaRepresentation.fromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12({ type: "string", pattern: "^(a+)+$" }) + ), + `Potentially unsafe pattern with nested unbounded repetition + at ["schema"]["pattern"]` + ) + }) + + it("allows opting out for trusted documents", () => { + const schema = SchemaRepresentation.fromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12({ type: "string", pattern: "^(a+)+$" }), + { unsafeAllowComplexPatterns: true } + ) + const is = Schema.is(schema) + assertTrue(is("aaa")) + assertFalse(is("a!")) + }) }) }) @@ -2076,6 +2097,20 @@ describe("fromJsonSchemaDocument", () => { ) }) + it("rejects unsafe patternProperties", () => { + throws( + () => + SchemaRepresentation.fromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12({ + type: "object", + patternProperties: { "^(a+)+$": { type: "string" } } + }) + ), + `Potentially unsafe pattern with nested unbounded repetition + at ["schema"]["patternProperties"]["^(a+)+$"]` + ) + }) + describe("checks", () => { it("minProperties", () => { assertFromJsonSchema( From bbdabca4b08d165c1bf4b2388a6937004a641daa Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 30 Jul 2026 11:56:22 +1200 Subject: [PATCH 2/4] Detect overlapping regex repetitions --- packages/effect/src/SchemaRepresentation.ts | 5 +- .../internal/schema/fromJsonSchemaDocument.ts | 169 +++++++++++++++--- .../fromJsonSchemaDocument.test.ts | 20 +++ 3 files changed, 167 insertions(+), 27 deletions(-) diff --git a/packages/effect/src/SchemaRepresentation.ts b/packages/effect/src/SchemaRepresentation.ts index b125786e546..32340e2b6b8 100644 --- a/packages/effect/src/SchemaRepresentation.ts +++ b/packages/effect/src/SchemaRepresentation.ts @@ -617,7 +617,8 @@ export const makeFilterGroupReviver:

( * * Patterns with potentially unsafe nested unbounded repetition are rejected by default. Set `unsafeAllowComplexPatterns` * to `true` only when imported documents are trusted; such patterns use the runtime's native regular expression engine - * and may block validation for an unbounded amount of time. + * and may block validation for an unbounded amount of time. The screen is a heuristic that detects a subset of + * catastrophic patterns, not a guarantee that accepted patterns are safe. * * `onEnter` must return a JSON Schema object. Its result is used directly, and exceptions raised by the callback pass * through unchanged. @@ -629,7 +630,7 @@ export interface FromJsonSchemaOptions { readonly onEnter?: ((schema: JsonSchema.JsonSchema) => JsonSchema.JsonSchema) | undefined /** * Allows patterns with potentially unsafe nested unbounded repetition. Enable only for trusted documents. Defaults to - * `false`. + * `false`. The default screen is heuristic and does not guarantee that accepted patterns are safe. */ readonly unsafeAllowComplexPatterns?: boolean | undefined } diff --git a/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts b/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts index 4f082cc7774..b954d3b2f0e 100644 --- a/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts +++ b/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts @@ -1,6 +1,7 @@ import { unescapeToken } from "../../JsonPointer.ts" import type * as JsonSchema from "../../JsonSchema.ts" import { remainder } from "../../Number.ts" +import * as Result from "../../Result.ts" import * as Schema from "../../Schema.ts" import * as SchemaAST from "../../SchemaAST.ts" import type * as SchemaRepresentation from "../../SchemaRepresentation.ts" @@ -124,29 +125,138 @@ function addNumberCheck( } } -// A mandatory atom separates repetitions. Without one, nested unbounded -// repetition can partition the same input exponentially many ways. +type PatternCharacterSetCategory = "digit" | "nonDigit" | "word" | "nonWord" | "space" | "nonSpace" + +interface PatternCharacterSet { + readonly ascii: bigint + readonly onlyAscii: boolean + readonly category: PatternCharacterSetCategory | undefined +} + +function patternCharacterSetCategory(source: string): PatternCharacterSetCategory | undefined { + switch (source) { + case "\\d": + return "digit" + case "\\D": + return "nonDigit" + case "\\w": + return "word" + case "\\W": + return "nonWord" + case "\\s": + return "space" + case "\\S": + return "nonSpace" + default: + return undefined + } +} + +function patternCharacterSet(source: string, onlyAscii: boolean): PatternCharacterSet | undefined { + const result = Result.try(() => new RegExp(`^(?:${source})$`)) + if (Result.isFailure(result)) return undefined + let ascii = BigInt(0) + for (let code = 0; code < 128; code++) { + if (result.success.test(String.fromCharCode(code))) { + ascii |= BigInt(1) << BigInt(code) + } + } + return { ascii, onlyAscii, category: patternCharacterSetCategory(source) } +} + +function isOnlyAsciiCharacterClass(source: string): boolean { + if (source[1] === "^") return false + for (let index = 1; index < source.length - 1; index++) { + const character = source[index] + if (character.charCodeAt(0) > 127) return false + if (character === "\\") { + const escaped = source[++index] + if (escaped === "d" || escaped === "w" || escaped !== undefined && !/[A-Za-z0-9]/.test(escaped)) { + continue + } + return false + } + } + return true +} + +function isOnlyAsciiAtom(source: string): boolean { + if (source.length === 1) return source !== "." && source.charCodeAt(0) < 128 + if (source === "\\d" || source === "\\w") return true + if (source.length === 2 && source[0] === "\\") { + return source.charCodeAt(1) < 128 && !/[A-Za-z0-9]/.test(source[1]) + } + return source[0] === "[" && isOnlyAsciiCharacterClass(source) +} + +function areDisjoint(left: PatternCharacterSet, right: PatternCharacterSet): boolean { + if ((left.ascii & right.ascii) !== BigInt(0)) return false + if (left.onlyAscii || right.onlyAscii) return true + return left.category === "digit" && right.category === "nonDigit" || + left.category === "nonDigit" && right.category === "digit" || + left.category === "word" && right.category === "nonWord" || + left.category === "nonWord" && right.category === "word" || + left.category === "space" && right.category === "nonSpace" || + left.category === "nonSpace" && right.category === "space" +} + +function unionCharacterSets( + sets: ReadonlyArray +): PatternCharacterSet | undefined { + if (sets.length === 0 || sets.some((set) => set === undefined)) return undefined + let ascii = BigInt(0) + let onlyAscii = true + for (const set of sets as ReadonlyArray) { + ascii |= set.ascii + onlyAscii &&= set.onlyAscii + } + return { ascii, onlyAscii, category: undefined } +} + +// A mandatory atom only separates repetitions when its character set cannot +// overlap the repeated atoms in neighboring iterations. function hasNestedUnboundedRepetition(pattern: string): boolean { + interface Atom { + readonly characterSet: PatternCharacterSet | undefined + readonly repeatedCharacterSets: Array + isMandatory: boolean + } interface Branch { - mandatoryAtoms: number - hasUnboundedRepetition: boolean + atoms: Array } interface Group { branches: Array branch: Branch - lastAtomWasMandatory: boolean } function makeGroup(): Group { return { branches: [], - branch: { mandatoryAtoms: 0, hasUnboundedRepetition: false }, - lastAtomWasMandatory: false + branch: { atoms: [] } } } - function addAtom(group: Group, hasUnboundedRepetition = false): void { - group.branch.mandatoryAtoms++ - group.branch.hasUnboundedRepetition ||= hasUnboundedRepetition - group.lastAtomWasMandatory = true + function addAtom( + group: Group, + characterSet: PatternCharacterSet | undefined, + repeatedCharacterSets: Array = [] + ): void { + group.branch.atoms.push({ characterSet, repeatedCharacterSets, isMandatory: true }) + } + function isUnsafeToRepeat(branch: Branch): boolean { + if (!branch.atoms.some((atom) => atom.repeatedCharacterSets.length > 0)) return false + const mandatoryAtoms = branch.atoms.filter((atom) => atom.isMandatory) + if (mandatoryAtoms.length <= 1) return true + return !mandatoryAtoms.some((candidate) => { + if (candidate.characterSet === undefined) return false + let compared = false + for (const atom of branch.atoms) { + if (atom === candidate) continue + for (const repeated of atom.repeatedCharacterSets) { + compared = true + if (repeated === undefined || !areDisjoint(candidate.characterSet, repeated)) return false + } + } + return compared + }) } const groups = [makeGroup()] @@ -155,17 +265,20 @@ function hasNestedUnboundedRepetition(pattern: string): boolean { for (let index = 0; index < pattern.length; index++) { const character = pattern[index] if (character === "\\") { - addAtom(groups[groups.length - 1]) - index++ + const source = pattern.slice(index, index + 2) + addAtom(groups[groups.length - 1], patternCharacterSet(source, isOnlyAsciiAtom(source))) + index += source.length - 1 previousWasClosedGroup = false continue } if (character === "[") { + const start = index for (index++; index < pattern.length; index++) { if (pattern[index] === "\\") index++ else if (pattern[index] === "]") break } - addAtom(groups[groups.length - 1]) + const source = pattern.slice(start, index + 1) + addAtom(groups[groups.length - 1], patternCharacterSet(source, isOnlyAsciiAtom(source))) previousWasClosedGroup = false continue } @@ -187,19 +300,21 @@ function hasNestedUnboundedRepetition(pattern: string): boolean { if (character === ")" && groups.length > 1) { const closed = groups.pop() as Group closed.branches.push(closed.branch) - const hasUnboundedRepetition = closed.branches.some((branch) => branch.hasUnboundedRepetition) - closedGroupIsUnsafeToRepeat = closed.branches.some((branch) => - branch.hasUnboundedRepetition && branch.mandatoryAtoms <= 1 + const repeatedCharacterSets = closed.branches.flatMap((branch) => + branch.atoms.flatMap((atom) => atom.repeatedCharacterSets) ) - addAtom(groups[groups.length - 1], hasUnboundedRepetition) + const characterSet = unionCharacterSets( + closed.branches.flatMap((branch) => branch.atoms.map((atom) => atom.characterSet)) + ) + closedGroupIsUnsafeToRepeat = closed.branches.some(isUnsafeToRepeat) + addAtom(groups[groups.length - 1], characterSet, repeatedCharacterSets) previousWasClosedGroup = true continue } const group = groups[groups.length - 1] if (character === "|") { group.branches.push(group.branch) - group.branch = { mandatoryAtoms: 0, hasUnboundedRepetition: false } - group.lastAtomWasMandatory = false + group.branch = { atoms: [] } previousWasClosedGroup = false continue } @@ -221,13 +336,17 @@ function hasNestedUnboundedRepetition(pattern: string): boolean { } if (isQuantifier) { if (isUnbounded && previousWasClosedGroup && closedGroupIsUnsafeToRepeat) return true - if (minimum === 0 && group.lastAtomWasMandatory) { - group.branch.mandatoryAtoms-- - group.lastAtomWasMandatory = false + const atom = group.branch.atoms[group.branch.atoms.length - 1] + // A lazy modifier `?` is treated as a quantifier, under-counting mandatory atoms over-conservatively. + if (minimum === 0 && atom !== undefined) { + atom.isMandatory = false + } + if (isUnbounded && atom !== undefined) { + atom.repeatedCharacterSets.push(atom.characterSet) } - group.branch.hasUnboundedRepetition ||= isUnbounded } else if (character !== "^" && character !== "$") { - addAtom(group) + const source = pattern[index] + addAtom(group, patternCharacterSet(source, isOnlyAsciiAtom(source))) } previousWasClosedGroup = false } diff --git a/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts b/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts index 9be6b7fc2c7..ca40e35b428 100644 --- a/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts +++ b/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts @@ -922,6 +922,26 @@ describe("fromJsonSchemaDocument", () => { ) }) + it("rejects overlapping mandatory atoms", () => { + throws( + () => + SchemaRepresentation.fromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12({ type: "string", pattern: "(x+x+)+y" }) + ), + `Potentially unsafe pattern with nested unbounded repetition + at ["schema"]["pattern"]` + ) + }) + + it("allows disjoint delimiters between repetitions", () => { + const schema = SchemaRepresentation.fromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12({ type: "string", pattern: "^(\\d+,)*\\d+$" }) + ) + const is = Schema.is(schema) + assertTrue(is("1,2,3")) + assertFalse(is("1,a")) + }) + it("allows opting out for trusted documents", () => { const schema = SchemaRepresentation.fromJsonSchemaDocument( JsonSchema.fromSchemaDraft2020_12({ type: "string", pattern: "^(a+)+$" }), From c504a9d43d5054e5ea3b136fca0304132602b8c6 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 30 Jul 2026 12:15:28 +1200 Subject: [PATCH 3/4] Check regex iteration boundaries --- .../internal/schema/fromJsonSchemaDocument.ts | 18 +++++++- .../fromJsonSchemaDocument.test.ts | 43 ++++++++++++++++--- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts b/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts index b954d3b2f0e..577d2a5e474 100644 --- a/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts +++ b/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts @@ -241,11 +241,26 @@ function hasNestedUnboundedRepetition(pattern: string): boolean { ): void { group.branch.atoms.push({ characterSet, repeatedCharacterSets, isMandatory: true }) } + function boundaryCharacterSets(branch: Branch, fromStart: boolean): Array { + const sets: Array = [] + for (let index = 0; index < branch.atoms.length; index++) { + const atom = branch.atoms[fromStart ? index : branch.atoms.length - index - 1] + sets.push(atom.characterSet) + if (atom.isMandatory) break + } + return sets + } + function hasDisjointIterationBoundaries(branch: Branch): boolean { + const start = boundaryCharacterSets(branch, true) + const end = boundaryCharacterSets(branch, false) + return start.length > 0 && end.length > 0 && + start.every((left) => left !== undefined && end.every((right) => right !== undefined && areDisjoint(left, right))) + } function isUnsafeToRepeat(branch: Branch): boolean { if (!branch.atoms.some((atom) => atom.repeatedCharacterSets.length > 0)) return false const mandatoryAtoms = branch.atoms.filter((atom) => atom.isMandatory) if (mandatoryAtoms.length <= 1) return true - return !mandatoryAtoms.some((candidate) => { + const hasDisjointSeparator = mandatoryAtoms.some((candidate) => { if (candidate.characterSet === undefined) return false let compared = false for (const atom of branch.atoms) { @@ -257,6 +272,7 @@ function hasNestedUnboundedRepetition(pattern: string): boolean { } return compared }) + return !hasDisjointSeparator || !hasDisjointIterationBoundaries(branch) } const groups = [makeGroup()] diff --git a/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts b/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts index ca40e35b428..9d66b0f2117 100644 --- a/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts +++ b/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts @@ -933,13 +933,44 @@ describe("fromJsonSchemaDocument", () => { ) }) - it("allows disjoint delimiters between repetitions", () => { - const schema = SchemaRepresentation.fromJsonSchemaDocument( - JsonSchema.fromSchemaDraft2020_12({ type: "string", pattern: "^(\\d+,)*\\d+$" }) + it("rejects overlapping iteration boundaries", () => { + for (const pattern of ["^(a+ba*)+$", "^(\\w+-\\w*)+$"]) { + throws( + () => + SchemaRepresentation.fromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12({ type: "string", pattern }) + ), + `Potentially unsafe pattern with nested unbounded repetition + at ["schema"]["pattern"]` + ) + } + }) + + it("allows disjoint iteration boundaries", () => { + for ( + const [pattern, input] of [ + ["^(\\d+,)*\\d+$", "1,2,3"], + ["^(\\d+,\\s*)+$", "1, 2,"], + ["^(a+b+)+$", "aababb"] + ] + ) { + const schema = SchemaRepresentation.fromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12({ type: "string", pattern }) + ) + assertTrue(Schema.is(schema)(input)) + } + }) + + it("rejects ambiguous class delimiters", () => { + // `_` is part of `\\w`, so this separator class overlaps the repeated word atoms. + throws( + () => + SchemaRepresentation.fromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12({ type: "string", pattern: "^(\\w+[-_.])*\\w+$" }) + ), + `Potentially unsafe pattern with nested unbounded repetition + at ["schema"]["pattern"]` ) - const is = Schema.is(schema) - assertTrue(is("1,2,3")) - assertFalse(is("1,a")) }) it("allows opting out for trusted documents", () => { From 4b611827d2e773724b2b48fa1a26cf768a6ea7d0 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 30 Jul 2026 12:50:32 +1200 Subject: [PATCH 4/4] Make imported patterns opt-in --- .changeset/safe-json-schema-patterns.md | 2 +- packages/effect/SCHEMA.md | 9 +- packages/effect/src/SchemaRepresentation.ts | 33 +- .../internal/schema/fromJsonSchemaDocument.ts | 286 ++---------------- .../fromJsonSchemaDocument.test.ts | 128 ++++---- 5 files changed, 103 insertions(+), 355 deletions(-) diff --git a/.changeset/safe-json-schema-patterns.md b/.changeset/safe-json-schema-patterns.md index 782b0c84450..ebe0787ae61 100644 --- a/.changeset/safe-json-schema-patterns.md +++ b/.changeset/safe-json-schema-patterns.md @@ -2,4 +2,4 @@ "effect": patch --- -Reject potentially unsafe regular expression patterns when importing JSON Schema documents by default, with an explicit opt-out for trusted documents. +Ignore regular expression patterns when importing JSON Schema documents by default, with explicit modes to apply trusted patterns or reject documents containing them. diff --git a/packages/effect/SCHEMA.md b/packages/effect/SCHEMA.md index cd898abbd4b..6de392a5944 100644 --- a/packages/effect/SCHEMA.md +++ b/packages/effect/SCHEMA.md @@ -6437,10 +6437,11 @@ Import is best-effort: JSON Schema constructs are translated to Effect schemas w lossless reconstruction of an original Effect schema. The optional `onEnter` callback can normalize each JSON Schema node before it is translated. -Imported `pattern` and `patternProperties` values are executed by the runtime's native regular expression engine during -validation. The importer rejects patterns with potentially unsafe nested unbounded repetition by default. Set -`unsafeAllowComplexPatterns: true` only when importing trusted documents and accepting the risk of validation blocking for -an unbounded amount of time. +Imported `pattern`, `patternProperties`, and patterns nested in `propertyNames` are ignored by default, so they do not +weaken availability by running in the runtime's native regular expression engine. Each skipped source is retained in an +`ignoredJsonSchemaPattern` annotation so the weaker validation remains observable. Set `patterns: "apply"` only when +importing trusted documents and accepting the risk of validation blocking for an unbounded amount of time. Set +`patterns: "error"` to reject documents containing patterns instead of weakening validation. ## Code generation diff --git a/packages/effect/src/SchemaRepresentation.ts b/packages/effect/src/SchemaRepresentation.ts index 32340e2b6b8..41feb2bf86a 100644 --- a/packages/effect/src/SchemaRepresentation.ts +++ b/packages/effect/src/SchemaRepresentation.ts @@ -615,10 +615,10 @@ export const makeFilterGroupReviver:

( * * **Gotchas** * - * Patterns with potentially unsafe nested unbounded repetition are rejected by default. Set `unsafeAllowComplexPatterns` - * to `true` only when imported documents are trusted; such patterns use the runtime's native regular expression engine - * and may block validation for an unbounded amount of time. The screen is a heuristic that detects a subset of - * catastrophic patterns, not a guarantee that accepted patterns are safe. + * JSON Schema patterns are ignored by default and their source is retained in an `ignoredJsonSchemaPattern` annotation. + * Use `patterns: "apply"` only for trusted documents because validation uses the runtime's native regular expression + * engine and may block for an unbounded amount of time. Use `patterns: "error"` to reject documents containing patterns + * instead of weakening validation. * * `onEnter` must return a JSON Schema object. Its result is used directly, and exceptions raised by the callback pass * through unchanged. @@ -629,10 +629,12 @@ export const makeFilterGroupReviver:

( export interface FromJsonSchemaOptions { readonly onEnter?: ((schema: JsonSchema.JsonSchema) => JsonSchema.JsonSchema) | undefined /** - * Allows patterns with potentially unsafe nested unbounded repetition. Enable only for trusted documents. Defaults to - * `false`. The default screen is heuristic and does not guarantee that accepted patterns are safe. + * Controls how `pattern`, `patternProperties`, and patterns nested in `propertyNames` are imported. Defaults to + * `"ignore"`, which records the skipped source in an `ignoredJsonSchemaPattern` annotation. `"apply"` compiles and + * enforces patterns with the runtime's native regular expression engine. `"error"` rejects a document containing a + * pattern. */ - readonly unsafeAllowComplexPatterns?: boolean | undefined + readonly patterns?: "ignore" | "apply" | "error" | undefined } /** @@ -1177,10 +1179,11 @@ export function fromRepresentations( * * **Gotchas** * - * Import is best-effort. Built-in declarations and checks are reconstructed with importer-owned revivers. Patterns with - * potentially unsafe nested unbounded repetition are rejected by default because validation uses the runtime's native - * regular expression engine. Use `unsafeAllowComplexPatterns` only for trusted documents. Callback results are used - * directly, and exceptions raised by a callback pass through unchanged. + * Import is best-effort. Built-in declarations and checks are reconstructed with importer-owned revivers. Patterns are + * ignored by default and recorded in an `ignoredJsonSchemaPattern` annotation. Use `patterns: "apply"` only for trusted + * documents because validation uses the runtime's native regular expression engine, or `patterns: "error"` to reject + * documents containing patterns. Callback results are used directly, and exceptions raised by a callback pass through + * unchanged. * * @see {@link fromJsonSchemaMultiDocument} for multiple roots sharing definitions * @see {@link toRepresentation} for converting the result to a representation document @@ -1204,10 +1207,10 @@ export function fromJsonSchemaDocument( * * **Gotchas** * - * Every definition is translated, including definitions that no root references. Patterns with potentially unsafe nested - * unbounded repetition are rejected by default because validation uses the runtime's native regular expression engine. - * Use `unsafeAllowComplexPatterns` only for trusted documents. Callback results are used directly, and exceptions raised - * by a callback pass through unchanged. + * Every definition is translated, including definitions that no root references. Patterns are ignored by default and + * recorded in an `ignoredJsonSchemaPattern` annotation. Use `patterns: "apply"` only for trusted documents because + * validation uses the runtime's native regular expression engine, or `patterns: "error"` to reject documents containing + * patterns. Callback results are used directly, and exceptions raised by a callback pass through unchanged. * * @see {@link fromJsonSchemaDocument} for a single root * @see {@link fromSchemaMultiDocument} for converting the result to a representation document diff --git a/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts b/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts index 577d2a5e474..2c0be4cc3a2 100644 --- a/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts +++ b/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts @@ -1,7 +1,6 @@ import { unescapeToken } from "../../JsonPointer.ts" import type * as JsonSchema from "../../JsonSchema.ts" import { remainder } from "../../Number.ts" -import * as Result from "../../Result.ts" import * as Schema from "../../Schema.ts" import * as SchemaAST from "../../SchemaAST.ts" import type * as SchemaRepresentation from "../../SchemaRepresentation.ts" @@ -125,250 +124,6 @@ function addNumberCheck( } } -type PatternCharacterSetCategory = "digit" | "nonDigit" | "word" | "nonWord" | "space" | "nonSpace" - -interface PatternCharacterSet { - readonly ascii: bigint - readonly onlyAscii: boolean - readonly category: PatternCharacterSetCategory | undefined -} - -function patternCharacterSetCategory(source: string): PatternCharacterSetCategory | undefined { - switch (source) { - case "\\d": - return "digit" - case "\\D": - return "nonDigit" - case "\\w": - return "word" - case "\\W": - return "nonWord" - case "\\s": - return "space" - case "\\S": - return "nonSpace" - default: - return undefined - } -} - -function patternCharacterSet(source: string, onlyAscii: boolean): PatternCharacterSet | undefined { - const result = Result.try(() => new RegExp(`^(?:${source})$`)) - if (Result.isFailure(result)) return undefined - let ascii = BigInt(0) - for (let code = 0; code < 128; code++) { - if (result.success.test(String.fromCharCode(code))) { - ascii |= BigInt(1) << BigInt(code) - } - } - return { ascii, onlyAscii, category: patternCharacterSetCategory(source) } -} - -function isOnlyAsciiCharacterClass(source: string): boolean { - if (source[1] === "^") return false - for (let index = 1; index < source.length - 1; index++) { - const character = source[index] - if (character.charCodeAt(0) > 127) return false - if (character === "\\") { - const escaped = source[++index] - if (escaped === "d" || escaped === "w" || escaped !== undefined && !/[A-Za-z0-9]/.test(escaped)) { - continue - } - return false - } - } - return true -} - -function isOnlyAsciiAtom(source: string): boolean { - if (source.length === 1) return source !== "." && source.charCodeAt(0) < 128 - if (source === "\\d" || source === "\\w") return true - if (source.length === 2 && source[0] === "\\") { - return source.charCodeAt(1) < 128 && !/[A-Za-z0-9]/.test(source[1]) - } - return source[0] === "[" && isOnlyAsciiCharacterClass(source) -} - -function areDisjoint(left: PatternCharacterSet, right: PatternCharacterSet): boolean { - if ((left.ascii & right.ascii) !== BigInt(0)) return false - if (left.onlyAscii || right.onlyAscii) return true - return left.category === "digit" && right.category === "nonDigit" || - left.category === "nonDigit" && right.category === "digit" || - left.category === "word" && right.category === "nonWord" || - left.category === "nonWord" && right.category === "word" || - left.category === "space" && right.category === "nonSpace" || - left.category === "nonSpace" && right.category === "space" -} - -function unionCharacterSets( - sets: ReadonlyArray -): PatternCharacterSet | undefined { - if (sets.length === 0 || sets.some((set) => set === undefined)) return undefined - let ascii = BigInt(0) - let onlyAscii = true - for (const set of sets as ReadonlyArray) { - ascii |= set.ascii - onlyAscii &&= set.onlyAscii - } - return { ascii, onlyAscii, category: undefined } -} - -// A mandatory atom only separates repetitions when its character set cannot -// overlap the repeated atoms in neighboring iterations. -function hasNestedUnboundedRepetition(pattern: string): boolean { - interface Atom { - readonly characterSet: PatternCharacterSet | undefined - readonly repeatedCharacterSets: Array - isMandatory: boolean - } - interface Branch { - atoms: Array - } - interface Group { - branches: Array - branch: Branch - } - function makeGroup(): Group { - return { - branches: [], - branch: { atoms: [] } - } - } - function addAtom( - group: Group, - characterSet: PatternCharacterSet | undefined, - repeatedCharacterSets: Array = [] - ): void { - group.branch.atoms.push({ characterSet, repeatedCharacterSets, isMandatory: true }) - } - function boundaryCharacterSets(branch: Branch, fromStart: boolean): Array { - const sets: Array = [] - for (let index = 0; index < branch.atoms.length; index++) { - const atom = branch.atoms[fromStart ? index : branch.atoms.length - index - 1] - sets.push(atom.characterSet) - if (atom.isMandatory) break - } - return sets - } - function hasDisjointIterationBoundaries(branch: Branch): boolean { - const start = boundaryCharacterSets(branch, true) - const end = boundaryCharacterSets(branch, false) - return start.length > 0 && end.length > 0 && - start.every((left) => left !== undefined && end.every((right) => right !== undefined && areDisjoint(left, right))) - } - function isUnsafeToRepeat(branch: Branch): boolean { - if (!branch.atoms.some((atom) => atom.repeatedCharacterSets.length > 0)) return false - const mandatoryAtoms = branch.atoms.filter((atom) => atom.isMandatory) - if (mandatoryAtoms.length <= 1) return true - const hasDisjointSeparator = mandatoryAtoms.some((candidate) => { - if (candidate.characterSet === undefined) return false - let compared = false - for (const atom of branch.atoms) { - if (atom === candidate) continue - for (const repeated of atom.repeatedCharacterSets) { - compared = true - if (repeated === undefined || !areDisjoint(candidate.characterSet, repeated)) return false - } - } - return compared - }) - return !hasDisjointSeparator || !hasDisjointIterationBoundaries(branch) - } - - const groups = [makeGroup()] - let closedGroupIsUnsafeToRepeat = false - let previousWasClosedGroup = false - for (let index = 0; index < pattern.length; index++) { - const character = pattern[index] - if (character === "\\") { - const source = pattern.slice(index, index + 2) - addAtom(groups[groups.length - 1], patternCharacterSet(source, isOnlyAsciiAtom(source))) - index += source.length - 1 - previousWasClosedGroup = false - continue - } - if (character === "[") { - const start = index - for (index++; index < pattern.length; index++) { - if (pattern[index] === "\\") index++ - else if (pattern[index] === "]") break - } - const source = pattern.slice(start, index + 1) - addAtom(groups[groups.length - 1], patternCharacterSet(source, isOnlyAsciiAtom(source))) - previousWasClosedGroup = false - continue - } - if (character === "(") { - groups.push(makeGroup()) - if (pattern[index + 1] === "?") { - if (pattern[index + 2] === "<" && pattern[index + 3] !== "=" && pattern[index + 3] !== "!") { - const end = pattern.indexOf(">", index + 3) - if (end !== -1) index = end - } else if (pattern[index + 2] === "<") { - index += 3 - } else { - index += 2 - } - } - previousWasClosedGroup = false - continue - } - if (character === ")" && groups.length > 1) { - const closed = groups.pop() as Group - closed.branches.push(closed.branch) - const repeatedCharacterSets = closed.branches.flatMap((branch) => - branch.atoms.flatMap((atom) => atom.repeatedCharacterSets) - ) - const characterSet = unionCharacterSets( - closed.branches.flatMap((branch) => branch.atoms.map((atom) => atom.characterSet)) - ) - closedGroupIsUnsafeToRepeat = closed.branches.some(isUnsafeToRepeat) - addAtom(groups[groups.length - 1], characterSet, repeatedCharacterSets) - previousWasClosedGroup = true - continue - } - const group = groups[groups.length - 1] - if (character === "|") { - group.branches.push(group.branch) - group.branch = { atoms: [] } - previousWasClosedGroup = false - continue - } - let isQuantifier = character === "*" || character === "+" || character === "?" - let isUnbounded = character === "*" || character === "+" - let minimum = character === "*" || character === "?" ? 0 : 1 - if (character === "{") { - const end = pattern.indexOf("}", index + 1) - if (end !== -1) { - const content = pattern.slice(index + 1, end) - const match = /^(\d+)(?:,(\d*))?$/.exec(content) - if (match !== null) { - isQuantifier = true - minimum = Number(match[1]) - isUnbounded = content.includes(",") && match[2] === "" - index = end - } - } - } - if (isQuantifier) { - if (isUnbounded && previousWasClosedGroup && closedGroupIsUnsafeToRepeat) return true - const atom = group.branch.atoms[group.branch.atoms.length - 1] - // A lazy modifier `?` is treated as a quantifier, under-counting mandatory atoms over-conservatively. - if (minimum === 0 && atom !== undefined) { - atom.isMandatory = false - } - if (isUnbounded && atom !== undefined) { - atom.repeatedCharacterSets.push(atom.characterSet) - } - } else if (character !== "^" && character !== "$") { - const source = pattern[index] - addAtom(group, patternCharacterSet(source, isOnlyAsciiAtom(source))) - } - previousWasClosedGroup = false - } - return false -} - function jsonSchemaAnnotations( schema: JsonSchema.JsonSchema ): Schema.Annotations.Annotations | undefined { @@ -986,7 +741,7 @@ function translateJsonSchemaMultiDocument( case "string": return { _tag: "String", - checks: collectStringChecks(schema, path) + ...collectString(schema, path) } case "number": case "integer": @@ -1031,17 +786,33 @@ function translateJsonSchemaMultiDocument( } } - function collectStringChecks(schema: JsonSchema.JsonSchema, path: Path): Array { + function importPattern(pattern: string, path: Path): { + readonly check?: Check | undefined + readonly annotations?: Schema.Annotations.Annotations | undefined + } { + switch (options?.patterns ?? "ignore") { + case "ignore": + return { annotations: { ignoredJsonSchemaPattern: pattern } } + case "apply": + return { check: jsonSchemaFilter("effect/schema/isPattern", { source: pattern, flags: "" }) } + case "error": + throw errorWithPath(`Pattern encountered while patterns is set to "error"`, path) + } + } + + function collectString(schema: JsonSchema.JsonSchema, path: Path): { + readonly checks: Array + readonly annotations?: Schema.Annotations.Annotations | undefined + } { const checks: Array = [] addNumberCheck(checks, schema.minLength, "effect/schema/isMinLength", "minLength") addNumberCheck(checks, schema.maxLength, "effect/schema/isMaxLength", "maxLength") if (typeof schema.pattern === "string") { - if (options?.unsafeAllowComplexPatterns !== true && hasNestedUnboundedRepetition(schema.pattern)) { - throw errorWithPath("Potentially unsafe pattern with nested unbounded repetition", [...path, "pattern"]) - } - checks.push(jsonSchemaFilter("effect/schema/isPattern", { source: schema.pattern, flags: "" })) + const imported = importPattern(schema.pattern, [...path, "pattern"]) + if (imported.check !== undefined) checks.push(imported.check) + return { checks, annotations: imported.annotations } } - return checks + return { checks } } function collectNumberChecks(schema: JsonSchema.JsonSchema): Array { @@ -1097,17 +868,12 @@ function translateJsonSchemaMultiDocument( !Array.isArray(schema.patternProperties) ) { for (const [pattern, value] of Object.entries(schema.patternProperties)) { - if (options?.unsafeAllowComplexPatterns !== true && hasNestedUnboundedRepetition(pattern)) { - throw errorWithPath("Potentially unsafe pattern with nested unbounded repetition", [ - ...path, - "patternProperties", - pattern - ]) - } + const imported = importPattern(pattern, [...path, "patternProperties", pattern]) signatures.push({ parameter: { _tag: "String", - checks: [jsonSchemaFilter("effect/schema/isPattern", { source: pattern, flags: "" })] + checks: imported.check === undefined ? [] : [imported.check], + ...imported.annotations === undefined ? undefined : { annotations: imported.annotations } }, type: recur(value, [...path, "patternProperties", pattern]) }) diff --git a/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts b/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts index 9d66b0f2117..2f492477cd3 100644 --- a/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts +++ b/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts @@ -6,14 +6,16 @@ function toSchemaFromJsonSchemaDocument( document: JsonSchema.Document<"draft-2020-12">, options?: SchemaRepresentation.FromJsonSchemaOptions ): Schema.Top { - return SchemaRepresentation.fromJsonSchemaDocument(document, options) + return SchemaRepresentation.fromJsonSchemaDocument(document, { patterns: "apply", ...options }) } function fromJsonSchemaRepresentation( document: JsonSchema.Document<"draft-2020-12">, options?: SchemaRepresentation.FromJsonSchemaOptions ): SchemaRepresentation.Document { - return SchemaRepresentation.toRepresentation(SchemaRepresentation.fromJsonSchemaDocument(document, options).ast) + return SchemaRepresentation.toRepresentation( + SchemaRepresentation.fromJsonSchemaDocument(document, { patterns: "apply", ...options }).ast + ) } describe("fromJsonSchemaDocument", () => { @@ -25,7 +27,7 @@ describe("fromJsonSchemaDocument", () => { expected: Schema.Json ) { const jsonDocument = JsonSchema.fromSchemaDraft2020_12(input.schema) - const schema = SchemaRepresentation.fromJsonSchemaDocument(jsonDocument, input.options) + const schema = SchemaRepresentation.fromJsonSchemaDocument(jsonDocument, { patterns: "apply", ...input.options }) const document = SchemaRepresentation.toRepresentation(schema.ast) deepStrictEqual(SchemaRepresentation.toJson(document), expected) return schema @@ -911,77 +913,67 @@ describe("fromJsonSchemaDocument", () => { ) }) - it("rejects patterns with nested unbounded repetition", () => { - throws( - () => - SchemaRepresentation.fromJsonSchemaDocument( - JsonSchema.fromSchemaDraft2020_12({ type: "string", pattern: "^(a+)+$" }) - ), - `Potentially unsafe pattern with nested unbounded repetition - at ["schema"]["pattern"]` + it("ignores patterns by default and annotates the skip", () => { + const schema = SchemaRepresentation.fromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12({ type: "string", pattern: "^a+$" }) ) + const is = Schema.is(schema) + assertTrue(is("aaa")) + assertTrue(is("bbb")) + deepStrictEqual(Schema.resolveAnnotations(schema), { ignoredJsonSchemaPattern: "^a+$" }) + + const object = SchemaRepresentation.fromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12({ + type: "object", + patternProperties: { "^a+$": { type: "string" } }, + additionalProperties: false + }) + ) + const representation = SchemaRepresentation.toRepresentation(object.ast).representation + strictEqual(representation._tag, "Objects") + if (representation._tag === "Objects") { + const parameter = representation.indexSignatures[0].parameter + strictEqual(parameter._tag, "String") + if (parameter._tag === "String") { + deepStrictEqual(parameter.annotations, { ignoredJsonSchemaPattern: "^a+$" }) + } + } }) - it("rejects overlapping mandatory atoms", () => { - throws( - () => - SchemaRepresentation.fromJsonSchemaDocument( - JsonSchema.fromSchemaDraft2020_12({ type: "string", pattern: "(x+x+)+y" }) - ), - `Potentially unsafe pattern with nested unbounded repetition - at ["schema"]["pattern"]` + it("applies patterns explicitly", () => { + const schema = SchemaRepresentation.fromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12({ type: "string", pattern: "^a+$" }), + { patterns: "apply" } ) + const is = Schema.is(schema) + assertTrue(is("aaa")) + assertFalse(is("bbb")) }) - it("rejects overlapping iteration boundaries", () => { - for (const pattern of ["^(a+ba*)+$", "^(\\w+-\\w*)+$"]) { + it("rejects patterns explicitly", () => { + for ( + const [schema, path] of [ + [{ type: "string", pattern: "^a+$" }, `["schema"]["pattern"]`], + [ + { type: "object", patternProperties: { "^a+$": { type: "string" } } }, + `["schema"]["patternProperties"]["^a+$"]` + ], + [ + { type: "object", propertyNames: { pattern: "^a+$" } }, + `["schema"]["propertyNames"]["pattern"]` + ] + ] as const + ) { throws( () => SchemaRepresentation.fromJsonSchemaDocument( - JsonSchema.fromSchemaDraft2020_12({ type: "string", pattern }) + JsonSchema.fromSchemaDraft2020_12(schema), + { patterns: "error" } ), - `Potentially unsafe pattern with nested unbounded repetition - at ["schema"]["pattern"]` - ) - } - }) - - it("allows disjoint iteration boundaries", () => { - for ( - const [pattern, input] of [ - ["^(\\d+,)*\\d+$", "1,2,3"], - ["^(\\d+,\\s*)+$", "1, 2,"], - ["^(a+b+)+$", "aababb"] - ] - ) { - const schema = SchemaRepresentation.fromJsonSchemaDocument( - JsonSchema.fromSchemaDraft2020_12({ type: "string", pattern }) + `Pattern encountered while patterns is set to "error"\n at ${path}` ) - assertTrue(Schema.is(schema)(input)) } }) - - it("rejects ambiguous class delimiters", () => { - // `_` is part of `\\w`, so this separator class overlaps the repeated word atoms. - throws( - () => - SchemaRepresentation.fromJsonSchemaDocument( - JsonSchema.fromSchemaDraft2020_12({ type: "string", pattern: "^(\\w+[-_.])*\\w+$" }) - ), - `Potentially unsafe pattern with nested unbounded repetition - at ["schema"]["pattern"]` - ) - }) - - it("allows opting out for trusted documents", () => { - const schema = SchemaRepresentation.fromJsonSchemaDocument( - JsonSchema.fromSchemaDraft2020_12({ type: "string", pattern: "^(a+)+$" }), - { unsafeAllowComplexPatterns: true } - ) - const is = Schema.is(schema) - assertTrue(is("aaa")) - assertFalse(is("a!")) - }) }) }) @@ -2148,20 +2140,6 @@ describe("fromJsonSchemaDocument", () => { ) }) - it("rejects unsafe patternProperties", () => { - throws( - () => - SchemaRepresentation.fromJsonSchemaDocument( - JsonSchema.fromSchemaDraft2020_12({ - type: "object", - patternProperties: { "^(a+)+$": { type: "string" } } - }) - ), - `Potentially unsafe pattern with nested unbounded repetition - at ["schema"]["patternProperties"]["^(a+)+$"]` - ) - }) - describe("checks", () => { it("minProperties", () => { assertFromJsonSchema(