diff --git a/.changeset/safe-json-schema-patterns.md b/.changeset/safe-json-schema-patterns.md
new file mode 100644
index 00000000000..ebe0787ae61
--- /dev/null
+++ b/.changeset/safe-json-schema-patterns.md
@@ -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.
diff --git a/packages/effect/SCHEMA.md b/packages/effect/SCHEMA.md
index 1e437f1d2dd..6de392a5944 100644
--- a/packages/effect/SCHEMA.md
+++ b/packages/effect/SCHEMA.md
@@ -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`
diff --git a/packages/effect/src/SchemaRepresentation.ts b/packages/effect/src/SchemaRepresentation.ts
index 7796ec4c50b..41feb2bf86a 100644
--- a/packages/effect/src/SchemaRepresentation.ts
+++ b/packages/effect/src/SchemaRepresentation.ts
@@ -615,13 +615,26 @@ 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.
+ * 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
}
/**
@@ -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
@@ -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
diff --git a/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts b/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts
index 8301d808e72..2c0be4cc3a2 100644
--- a/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts
+++ b/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts
@@ -741,7 +741,7 @@ function translateJsonSchemaMultiDocument(
case "string":
return {
_tag: "String",
- checks: collectStringChecks(schema)
+ ...collectString(schema, path)
}
case "number":
case "integer":
@@ -786,14 +786,33 @@ function translateJsonSchemaMultiDocument(
}
}
- function collectStringChecks(schema: JsonSchema.JsonSchema): 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") {
- 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 {
@@ -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])
})
diff --git a/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts b/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts
index f272dd61306..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
@@ -844,7 +846,7 @@ describe("fromJsonSchemaDocument", () => {
)
})
- it("pattern", () => {
+ it("round-trips ordinary patterns", () => {
assertFromJsonSchema(
{ schema: { type: "string", pattern: "a*" } },
{
@@ -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}`
+ )
+ }
+ })
})
})