82c24a3f74
- JSON Schema validator now enforces propertyNames, patternProperties, dependentRequired, dependencies, if/then/else, contains, and prefixItems instead of silently accepting values that violate them; unevaluated* still permissive but warns once. - Recursive $ref no longer short-circuits to true on revisit: cycle detection keys on (ref, value-identity) with a primitive depth cap, so nested sub-schema violations are caught. - Meta-validator now structurally validates if/then/else/dependencies sub-schemas and accepts draft-07 dependencies as either schemas or string-array dependent keys. - Wire-schema null normalization no longer strips null-valued unknown root fields before preserveUnknownRootFields snapshots them, so task.simple and similar callers still see disallowed null arguments.
172 lines
6.0 KiB
TypeScript
172 lines
6.0 KiB
TypeScript
import { areJsonValuesEqual } from "./equality";
|
|
|
|
/**
|
|
* Hand-rolled JSON Schema meta-validator.
|
|
*
|
|
* Replaces the old AJV meta-schema check in request hot paths with a small
|
|
* structural validator for the JSON Schema subset this repo emits and forwards.
|
|
* Unknown keywords are accepted for forward compatibility; known keywords are
|
|
* checked so malformed provider payloads still fall back instead of being sent.
|
|
*/
|
|
|
|
type Json = unknown;
|
|
|
|
function isPlainObject(value: Json): value is Record<string, Json> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
const TYPE_NAMES = new Set<string>(["string", "number", "integer", "boolean", "object", "array", "null"]);
|
|
|
|
function isNonNegativeInteger(value: Json): value is number {
|
|
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
}
|
|
|
|
function hasUniqueJsonValues(values: readonly unknown[]): boolean {
|
|
for (let i = 0; i < values.length; i += 1) {
|
|
for (let j = i + 1; j < values.length; j += 1) {
|
|
if (areJsonValuesEqual(values[i], values[j])) return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function checkTypeKeyword(value: Json): boolean {
|
|
if (typeof value === "string") return TYPE_NAMES.has(value);
|
|
if (!Array.isArray(value) || value.length === 0) return false;
|
|
const seen = new Set<string>();
|
|
for (const entry of value) {
|
|
if (typeof entry !== "string" || !TYPE_NAMES.has(entry) || seen.has(entry)) return false;
|
|
seen.add(entry);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function checkSchemaArray(value: Json, seen: WeakSet<object>): boolean {
|
|
return Array.isArray(value) && value.every(entry => checkNode(entry, seen));
|
|
}
|
|
|
|
function checkSchemaMap(value: Json, seen: WeakSet<object>): boolean {
|
|
if (!isPlainObject(value)) return false;
|
|
return Object.values(value).every(sub => checkNode(sub, seen));
|
|
}
|
|
|
|
/** Validate a single sub-schema node. */
|
|
function checkNode(node: Json, seen: WeakSet<object>): boolean {
|
|
// Boolean schemas (`true` / `false`) are valid JSON Schema.
|
|
if (node === true || node === false) return true;
|
|
if (!isPlainObject(node)) return false;
|
|
if (seen.has(node)) return true;
|
|
seen.add(node);
|
|
|
|
if ("type" in node && !checkTypeKeyword(node.type)) return false;
|
|
|
|
for (const key of ["anyOf", "oneOf", "allOf"] as const) {
|
|
if (key in node && !checkSchemaArray(node[key], seen)) return false;
|
|
}
|
|
if ("not" in node && !checkNode(node.not, seen)) return false;
|
|
for (const key of ["if", "then", "else"] as const) {
|
|
if (key in node && !checkNode(node[key], seen)) return false;
|
|
}
|
|
|
|
for (const key of ["properties", "patternProperties", "$defs", "definitions"] as const) {
|
|
if (key in node && !checkSchemaMap(node[key], seen)) return false;
|
|
}
|
|
|
|
if ("propertyNames" in node && !checkNode(node.propertyNames, seen)) return false;
|
|
if ("contains" in node && !checkNode(node.contains, seen)) return false;
|
|
|
|
if ("required" in node) {
|
|
const value = node.required;
|
|
if (!Array.isArray(value)) return false;
|
|
const seenRequired = new Set<string>();
|
|
for (const entry of value) {
|
|
if (typeof entry !== "string" || seenRequired.has(entry)) return false;
|
|
seenRequired.add(entry);
|
|
}
|
|
}
|
|
|
|
if ("items" in node) {
|
|
const items = node.items;
|
|
if (Array.isArray(items)) {
|
|
for (const entry of items) {
|
|
if (!checkNode(entry, seen)) return false;
|
|
}
|
|
} else if (!checkNode(items, seen)) return false;
|
|
}
|
|
if ("prefixItems" in node && !checkSchemaArray(node.prefixItems, seen)) return false;
|
|
|
|
for (const key of [
|
|
"additionalProperties",
|
|
"additionalItems",
|
|
"unevaluatedProperties",
|
|
"unevaluatedItems",
|
|
] as const) {
|
|
if (!(key in node)) continue;
|
|
const value = node[key];
|
|
if (typeof value !== "boolean" && !checkNode(value, seen)) return false;
|
|
}
|
|
|
|
if ("dependentSchemas" in node && !checkSchemaMap(node.dependentSchemas, seen)) return false;
|
|
if ("dependentRequired" in node) {
|
|
const value = node.dependentRequired;
|
|
if (!isPlainObject(value)) return false;
|
|
for (const entry of Object.values(value)) {
|
|
if (!Array.isArray(entry) || !entry.every(item => typeof item === "string")) return false;
|
|
}
|
|
}
|
|
|
|
// Draft-07 `dependencies`: each entry is either a schema or a string[].
|
|
if ("dependencies" in node) {
|
|
const value = node.dependencies;
|
|
if (!isPlainObject(value)) return false;
|
|
for (const entry of Object.values(value)) {
|
|
if (Array.isArray(entry)) {
|
|
if (!entry.every(item => typeof item === "string")) return false;
|
|
} else if (!checkNode(entry, seen)) return false;
|
|
}
|
|
}
|
|
|
|
if ("enum" in node) {
|
|
if (!Array.isArray(node.enum) || node.enum.length === 0 || !hasUniqueJsonValues(node.enum)) return false;
|
|
}
|
|
|
|
for (const key of ["minimum", "maximum", "multipleOf"] as const) {
|
|
if (key in node && typeof node[key] !== "number") return false;
|
|
}
|
|
if (node.multipleOf !== undefined && typeof node.multipleOf === "number" && node.multipleOf <= 0) return false;
|
|
for (const key of ["exclusiveMinimum", "exclusiveMaximum"] as const) {
|
|
if (key in node && typeof node[key] !== "number" && typeof node[key] !== "boolean") return false;
|
|
}
|
|
for (const key of ["minLength", "maxLength", "minItems", "maxItems", "minProperties", "maxProperties"] as const) {
|
|
if (key in node && !isNonNegativeInteger(node[key])) return false;
|
|
}
|
|
for (const key of ["minContains", "maxContains"] as const) {
|
|
if (key in node && !isNonNegativeInteger(node[key])) return false;
|
|
}
|
|
if ("uniqueItems" in node && typeof node.uniqueItems !== "boolean") return false;
|
|
if ("pattern" in node) {
|
|
if (typeof node.pattern !== "string") return false;
|
|
try {
|
|
new RegExp(node.pattern);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
if ("format" in node && typeof node.format !== "string") return false;
|
|
if ("nullable" in node && typeof node.nullable !== "boolean") return false;
|
|
if ("readOnly" in node && typeof node.readOnly !== "boolean") return false;
|
|
if ("writeOnly" in node && typeof node.writeOnly !== "boolean") return false;
|
|
if ("deprecated" in node && typeof node.deprecated !== "boolean") return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
/** Validate that `schema` is structurally a valid JSON Schema (subset). */
|
|
export function isValidJsonSchema(schema: unknown): boolean {
|
|
try {
|
|
return checkNode(schema, new WeakSet<object>());
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|