Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/safe-json-schema-patterns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": patch
---

Ignore regular expression patterns when importing JSON Schema documents by default, with explicit modes to apply trusted patterns or reject documents containing them.
6 changes: 6 additions & 0 deletions packages/effect/SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -6437,6 +6437,12 @@ 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`, `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

### `toCodeDocument`
Expand Down
26 changes: 23 additions & 3 deletions packages/effect/src/SchemaRepresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,13 +615,26 @@ export const makeFilterGroupReviver: <P>(
*
* **Gotchas**
*
* `onEnter` must return a JSON Schema object. Its result is used directly, and exceptions raised by the callback pass through unchanged.
* 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.
*
* @category models
* @since 4.0.0
*/
export interface FromJsonSchemaOptions {
readonly onEnter?: ((schema: JsonSchema.JsonSchema) => JsonSchema.JsonSchema) | undefined
/**
* 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 patterns?: "ignore" | "apply" | "error" | undefined
}

/**
Expand Down Expand Up @@ -1166,7 +1179,11 @@ 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 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
Expand All @@ -1190,7 +1207,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 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
Expand Down
31 changes: 26 additions & 5 deletions packages/effect/src/internal/schema/fromJsonSchemaDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,7 +741,7 @@ function translateJsonSchemaMultiDocument(
case "string":
return {
_tag: "String",
checks: collectStringChecks(schema)
...collectString(schema, path)
}
case "number":
case "integer":
Expand Down Expand Up @@ -786,14 +786,33 @@ function translateJsonSchemaMultiDocument(
}
}

function collectStringChecks(schema: JsonSchema.JsonSchema): Array<Check> {
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<Check>
readonly annotations?: Schema.Annotations.Annotations | undefined
} {
const checks: Array<Check> = []
addNumberCheck(checks, schema.minLength, "effect/schema/isMinLength", "minLength")
addNumberCheck(checks, schema.maxLength, "effect/schema/isMaxLength", "maxLength")
if (typeof schema.pattern === "string") {
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<Check> {
Expand Down Expand Up @@ -849,10 +868,12 @@ function translateJsonSchemaMultiDocument(
!Array.isArray(schema.patternProperties)
) {
for (const [pattern, value] of Object.entries(schema.patternProperties)) {
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])
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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
Expand Down Expand Up @@ -844,7 +846,7 @@ describe("fromJsonSchemaDocument", () => {
)
})

it("pattern", () => {
it("round-trips ordinary patterns", () => {
assertFromJsonSchema(
{ schema: { type: "string", pattern: "a*" } },
{
Expand Down Expand Up @@ -910,6 +912,68 @@ describe("fromJsonSchemaDocument", () => {
}
)
})

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("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 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(schema),
{ patterns: "error" }
),
`Pattern encountered while patterns is set to "error"\n at ${path}`
)
}
})
})
})

Expand Down
Loading