Something went wrong. Try again.
fork of gitlab.com/devnw/zig/json for zig 0.17
Something went wrong. Try again.
105 kB · 2506 lines
Zig
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507// SPDX-License-Identifier: Apache-2.0// Copyright (c) 2024-2025 CodePros
//! JSON Schema Draft 2020-12 Validator//!//! Implements JSON Schema compilation and validation with support for://! - All Core vocabulary keywords ($id, $ref, $anchor, $dynamicRef, $defs)//! - All Applicator vocabulary keywords (allOf, anyOf, oneOf, not, if/then/else)//! - All Validation vocabulary keywords (type, const, enum, numeric, string, array, object)//! - Unevaluated vocabulary keywords (unevaluatedItems, unevaluatedProperties)
const std = @import("std");const json = @import("json.zig");const pointer = @import("pointer.zig");const regex_mod = @import("regex_ecma262.zig");const number_mod = @import("number.zig");const unicode = @import("unicode.zig");const uri_mod = @import("uri");
const Value = json.Value;const Number = json.Number;const Member = json.Member;const Document = json.Document;const ErrorCode = json.ErrorCode;const ParseError = json.ParseError;const SchemaOptions = json.SchemaOptions;const Resolver = json.Resolver;const CompiledSchema = json.CompiledSchema;const ValidationError = json.ValidationError;const ValidationReport = json.ValidationReport;
/// Defense-in-depth structural recursion cap for schema compilation and/// validation. This is SEPARATE from the `$ref`-traversal depth (bounded by/// `SchemaOptions.max_ref_depth`): it bounds how deep the *structural* keyword/// nesting (`properties`/`items`/`allOf`/`anyOf`/`$defs`/...) may recurse.////// For schemas produced by `json.parse` this can never be hit -- the parser/// caps JSON nesting at `max_depth` (256 by default), and a schema's structural/// depth is bounded by its parsed nesting depth. The cap exists only to keep a/// caller that hand-builds a pathologically deep schema `Document`/`Value` tree/// (or a deeply nested instance) from overflowing the native stack: such input/// now returns a clean error (`.SchemaLimit` at compile time, a fail-closed/// validation error at validate time) instead of crashing.////// It is intentionally generous (>= the parser's default `max_depth=256`) so it/// NEVER affects real, parsed schemas. Kept as a module constant rather than a/// `SchemaOptions` field to avoid changing the shared `json.SchemaOptions` ABI.const max_schema_depth: u16 = 512;
pub const SchemaNode = struct { location: []const u8, /// The resolved base URI for this schema (from nearest $id ancestor) base_uri: []const u8 = "", id: ?[]const u8 = null, anchor: ?[]const u8 = null, dynamic_anchor: ?[]const u8 = null, ref: ?[]const u8 = null, dynamic_ref: ?[]const u8 = null,
schema_type: ?[]const []const u8 = null, const_val: ?Value = null, enum_vals: ?[]Value = null,
minimum: ?Number = null, maximum: ?Number = null, exclusive_minimum: ?Number = null, exclusive_maximum: ?Number = null, multiple_of: ?Number = null,
min_length: ?u64 = null, max_length: ?u64 = null, pattern: ?*regex_mod.Regex = null, format: ?[]const u8 = null,
min_items: ?u64 = null, max_items: ?u64 = null, unique_items: bool = false, items: ?*SchemaNode = null, prefix_items: ?[]*SchemaNode = null, contains: ?*SchemaNode = null, min_contains: ?u64 = null, max_contains: ?u64 = null,
min_properties: ?u64 = null, max_properties: ?u64 = null, required: ?[]const []const u8 = null, properties: ?std.StringHashMap(*SchemaNode) = null, pattern_properties: ?[]PatternProperty = null, additional_properties: ?*SchemaNode = null, property_names: ?*SchemaNode = null, dependent_required: ?std.StringHashMap([]const []const u8) = null, dependent_schemas: ?std.StringHashMap(*SchemaNode) = null,
all_of: ?[]*SchemaNode = null, any_of: ?[]*SchemaNode = null, one_of: ?[]*SchemaNode = null, not: ?*SchemaNode = null, if_schema: ?*SchemaNode = null, then_schema: ?*SchemaNode = null, else_schema: ?*SchemaNode = null,
unevaluated_items: ?*SchemaNode = null, unevaluated_properties: ?*SchemaNode = null,
defs: ?std.StringHashMap(*SchemaNode) = null,
is_bool_schema: bool = false, bool_value: bool = true,};
const PatternProperty = struct { pattern: *regex_mod.Regex, schema: *SchemaNode,};
pub fn compile(a: std.mem.Allocator, doc: *const Document, opts: SchemaOptions, resolver: ?Resolver, err: *ParseError) !CompiledSchema { var arena = std.heap.ArenaAllocator.init(a); errdefer arena.deinit(); const alloc = arena.allocator();
const root = try compileNode(alloc, doc.root, "", opts, err, 0);
var anchors = std.StringHashMap(*SchemaNode).init(alloc); var dynamic_anchors = std.StringHashMap(*SchemaNode).init(alloc); var id_registry = std.StringHashMap(*SchemaNode).init(alloc);
// Collect anchors and $id values with base URI context try collectIdentifiers(alloc, root, "", &anchors, &dynamic_anchors, &id_registry);
// Resolve external refs if resolver is provided if (resolver) |r| { var fetched = std.StringHashMap(void).init(alloc); try resolveExternalRefs(alloc, root, r, opts, err, &anchors, &dynamic_anchors, &id_registry, &fetched); }
return .{ .arena = arena, .root = root, .base_uri = "", .anchors = anchors, .dynamic_anchors = dynamic_anchors, .id_registry = id_registry, .resolver = resolver, .opts = opts, };}
/// Resolve a single external reffn resolveExternalRef( alloc: std.mem.Allocator, ref: []const u8, base_uri: []const u8, resolver: Resolver, opts: SchemaOptions, err: *ParseError, anchors: *std.StringHashMap(*SchemaNode), dynamic_anchors: *std.StringHashMap(*SchemaNode), id_registry: *std.StringHashMap(*SchemaNode), fetched: *std.StringHashMap(void),) std.mem.Allocator.Error!void { // Resolve relative ref against base URI if needed var resolved_ref: []const u8 = ref; var allocated = false;
if (std.mem.indexOf(u8, ref, ":") == null and base_uri.len > 0) { // Relative ref - resolve against base URI const parsed_ref = uri_mod.parse(ref) catch return; const parsed_base = uri_mod.parse(base_uri) catch return; var resolved = uri_mod.resolve(alloc, parsed_base, parsed_ref) catch return; defer resolved.deinit(alloc);
var aw: std.Io.Writer.Allocating = .init(alloc); resolved.format(&aw.writer) catch { aw.deinit(); return; }; resolved_ref = aw.toOwnedSlice() catch { aw.deinit(); return; }; allocated = true; } defer if (allocated) alloc.free(resolved_ref);
// Extract base URI (without fragment) const uri_base = if (std.mem.indexOf(u8, resolved_ref, "#")) |pos| resolved_ref[0..pos] else resolved_ref;
// Skip if already fetched or registered if (uri_base.len == 0 or fetched.contains(uri_base) or id_registry.contains(uri_base)) { return; }
// Mark as fetched to prevent infinite loops try fetched.put(try alloc.dupe(u8, uri_base), {});
// Try to resolve using the resolver const schema_json = resolver.resolve(resolver.ctx, uri_base, alloc) catch return;
// Parse the fetched JSON var parse_err = json.ParseError.init(); const ext_doc = json.parse(alloc, schema_json, .{}, &parse_err) catch return;
// Compile the external schema const ext_root = compileNode(alloc, ext_doc.root, "", opts, err, 0) catch return;
// Collect identifiers from the external schema try collectIdentifiers(alloc, ext_root, uri_base, anchors, dynamic_anchors, id_registry);
// Recursively resolve external refs in the fetched schema try resolveExternalRefs(alloc, ext_root, resolver, opts, err, anchors, dynamic_anchors, id_registry, fetched);}
/// Resolve external $refs by fetching and compiling external schemasfn resolveExternalRefs( alloc: std.mem.Allocator, node: *SchemaNode, resolver: Resolver, opts: SchemaOptions, err: *ParseError, anchors: *std.StringHashMap(*SchemaNode), dynamic_anchors: *std.StringHashMap(*SchemaNode), id_registry: *std.StringHashMap(*SchemaNode), fetched: *std.StringHashMap(void),) std.mem.Allocator.Error!void { // Check if this node has an external $ref if (node.ref) |ref| { // Skip fragment-only refs if (ref.len > 0 and ref[0] != '#') { try resolveExternalRef(alloc, ref, node.base_uri, resolver, opts, err, anchors, dynamic_anchors, id_registry, fetched); } }
// Recurse into child schemas if (node.defs) |defs| { var it = defs.iterator(); while (it.next()) |entry| { try resolveExternalRefs(alloc, entry.value_ptr.*, resolver, opts, err, anchors, dynamic_anchors, id_registry, fetched); } } if (node.properties) |props| { var it = props.iterator(); while (it.next()) |entry| { try resolveExternalRefs(alloc, entry.value_ptr.*, resolver, opts, err, anchors, dynamic_anchors, id_registry, fetched); } } if (node.items) |items| try resolveExternalRefs(alloc, items, resolver, opts, err, anchors, dynamic_anchors, id_registry, fetched); if (node.prefix_items) |items| for (items) |item| try resolveExternalRefs(alloc, item, resolver, opts, err, anchors, dynamic_anchors, id_registry, fetched); if (node.additional_properties) |ap| try resolveExternalRefs(alloc, ap, resolver, opts, err, anchors, dynamic_anchors, id_registry, fetched); if (node.contains) |c| try resolveExternalRefs(alloc, c, resolver, opts, err, anchors, dynamic_anchors, id_registry, fetched); if (node.all_of) |arr| for (arr) |s| try resolveExternalRefs(alloc, s, resolver, opts, err, anchors, dynamic_anchors, id_registry, fetched); if (node.any_of) |arr| for (arr) |s| try resolveExternalRefs(alloc, s, resolver, opts, err, anchors, dynamic_anchors, id_registry, fetched); if (node.one_of) |arr| for (arr) |s| try resolveExternalRefs(alloc, s, resolver, opts, err, anchors, dynamic_anchors, id_registry, fetched); if (node.not) |n| try resolveExternalRefs(alloc, n, resolver, opts, err, anchors, dynamic_anchors, id_registry, fetched); if (node.if_schema) |i| try resolveExternalRefs(alloc, i, resolver, opts, err, anchors, dynamic_anchors, id_registry, fetched); if (node.then_schema) |t| try resolveExternalRefs(alloc, t, resolver, opts, err, anchors, dynamic_anchors, id_registry, fetched); if (node.else_schema) |e| try resolveExternalRefs(alloc, e, resolver, opts, err, anchors, dynamic_anchors, id_registry, fetched); if (node.property_names) |pn| try resolveExternalRefs(alloc, pn, resolver, opts, err, anchors, dynamic_anchors, id_registry, fetched); if (node.dependent_schemas) |ds| { var it = ds.iterator(); while (it.next()) |entry| { try resolveExternalRefs(alloc, entry.value_ptr.*, resolver, opts, err, anchors, dynamic_anchors, id_registry, fetched); } } if (node.unevaluated_items) |ui| try resolveExternalRefs(alloc, ui, resolver, opts, err, anchors, dynamic_anchors, id_registry, fetched); if (node.unevaluated_properties) |up| try resolveExternalRefs(alloc, up, resolver, opts, err, anchors, dynamic_anchors, id_registry, fetched);}
/// Collect $id, $anchor, and $dynamicAnchor values with proper base URI resolutionfn collectIdentifiers( alloc: std.mem.Allocator, node: *SchemaNode, base_uri: []const u8, anchors: *std.StringHashMap(*SchemaNode), dynamic_anchors: *std.StringHashMap(*SchemaNode), id_registry: *std.StringHashMap(*SchemaNode),) !void { // Determine the new base URI for this node var current_base = base_uri; if (node.id) |id| { // Resolve $id against current base URI const resolved_id = try resolveIdUri(alloc, base_uri, id); current_base = resolved_id; try id_registry.put(resolved_id, node); }
// Store the base URI in the node for $ref resolution node.base_uri = current_base;
// Register anchors with their full URI (base + #anchor) if (node.anchor) |anch| { // Store both plain anchor and full URI form try anchors.put(anch, node); if (current_base.len > 0) { const full_anchor = try std.fmt.allocPrint(alloc, "{s}#{s}", .{ current_base, anch }); try anchors.put(full_anchor, node); } } if (node.dynamic_anchor) |danch| { // Only register plain anchor if not already registered (outermost wins per $dynamicRef spec) if (!dynamic_anchors.contains(danch)) { try dynamic_anchors.put(danch, node); } // Always register with full URI (uniquely identifies this specific anchor) if (current_base.len > 0) { const full_anchor = try std.fmt.allocPrint(alloc, "{s}#{s}", .{ current_base, danch }); try dynamic_anchors.put(full_anchor, node); } }
// Recurse into child schemas with updated base URI if (node.defs) |defs| { var it = defs.iterator(); while (it.next()) |entry| { try collectIdentifiers(alloc, entry.value_ptr.*, current_base, anchors, dynamic_anchors, id_registry); } } if (node.properties) |props| { var it = props.iterator(); while (it.next()) |entry| { try collectIdentifiers(alloc, entry.value_ptr.*, current_base, anchors, dynamic_anchors, id_registry); } } if (node.items) |items| try collectIdentifiers(alloc, items, current_base, anchors, dynamic_anchors, id_registry); if (node.prefix_items) |items| for (items) |item| try collectIdentifiers(alloc, item, current_base, anchors, dynamic_anchors, id_registry); if (node.additional_properties) |ap| try collectIdentifiers(alloc, ap, current_base, anchors, dynamic_anchors, id_registry); if (node.contains) |c| try collectIdentifiers(alloc, c, current_base, anchors, dynamic_anchors, id_registry); if (node.all_of) |arr| for (arr) |s| try collectIdentifiers(alloc, s, current_base, anchors, dynamic_anchors, id_registry); if (node.any_of) |arr| for (arr) |s| try collectIdentifiers(alloc, s, current_base, anchors, dynamic_anchors, id_registry); if (node.one_of) |arr| for (arr) |s| try collectIdentifiers(alloc, s, current_base, anchors, dynamic_anchors, id_registry); if (node.not) |n| try collectIdentifiers(alloc, n, current_base, anchors, dynamic_anchors, id_registry); if (node.if_schema) |i| try collectIdentifiers(alloc, i, current_base, anchors, dynamic_anchors, id_registry); if (node.then_schema) |t| try collectIdentifiers(alloc, t, current_base, anchors, dynamic_anchors, id_registry); if (node.else_schema) |e| try collectIdentifiers(alloc, e, current_base, anchors, dynamic_anchors, id_registry); if (node.property_names) |pn| try collectIdentifiers(alloc, pn, current_base, anchors, dynamic_anchors, id_registry); if (node.dependent_schemas) |ds| { var it = ds.iterator(); while (it.next()) |entry| { try collectIdentifiers(alloc, entry.value_ptr.*, current_base, anchors, dynamic_anchors, id_registry); } } if (node.unevaluated_items) |ui| try collectIdentifiers(alloc, ui, current_base, anchors, dynamic_anchors, id_registry); if (node.unevaluated_properties) |up| try collectIdentifiers(alloc, up, current_base, anchors, dynamic_anchors, id_registry);}
/// Resolve a $id URI against a base URIfn resolveIdUri(alloc: std.mem.Allocator, base: []const u8, id: []const u8) ![]const u8 { // If id is absolute (has scheme), use it as-is const parsed_id = uri_mod.parse(id) catch return try alloc.dupe(u8, id); if (parsed_id.scheme != null) { return try alloc.dupe(u8, id); }
// If no base, use id as-is if (base.len == 0) { return try alloc.dupe(u8, id); }
// Resolve relative id against base const parsed_base = uri_mod.parse(base) catch return try alloc.dupe(u8, id); var resolved = uri_mod.resolve(alloc, parsed_base, parsed_id) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return try alloc.dupe(u8, id), }; defer resolved.deinit(alloc); // Free every owned field.
// Format resolved URI back to string var aw: std.Io.Writer.Allocating = .init(alloc); resolved.format(&aw.writer) catch { aw.deinit(); return error.OutOfMemory; }; return aw.toOwnedSlice() catch { aw.deinit(); return error.OutOfMemory; };}
fn compileNode(a: std.mem.Allocator, v: Value, location: []const u8, opts: SchemaOptions, err: *ParseError, sdepth: u16) !*SchemaNode { // Defense-in-depth (NQUAL): bound the STRUCTURAL recursion depth so a // hand-built, pathologically deep schema tree fails compilation cleanly // rather than overflowing the native stack. Parsed schemas can never reach // this because the parser caps nesting well below `max_schema_depth`. if (sdepth > max_schema_depth) { err.* = .{ .code = .SchemaLimit, .line = 1, .col = 1, .offset = 0 }; return error.JsonParse; }
const node = try a.create(SchemaNode); node.* = .{ .location = try a.dupe(u8, location) };
switch (v) { .bool => |b| { node.is_bool_schema = true; node.bool_value = b; return node; }, .obj => |obj| { for (obj) |m| { if (std.mem.eql(u8, m.k, "$id")) { if (m.v == .str) node.id = try a.dupe(u8, m.v.str); } else if (std.mem.eql(u8, m.k, "$anchor")) { if (m.v == .str) node.anchor = try a.dupe(u8, m.v.str); } else if (std.mem.eql(u8, m.k, "$dynamicAnchor")) { if (m.v == .str) node.dynamic_anchor = try a.dupe(u8, m.v.str); } else if (std.mem.eql(u8, m.k, "$ref")) { if (m.v == .str) node.ref = try a.dupe(u8, m.v.str); } else if (std.mem.eql(u8, m.k, "$dynamicRef")) { if (m.v == .str) node.dynamic_ref = try a.dupe(u8, m.v.str); } else if (std.mem.eql(u8, m.k, "type")) { node.schema_type = try parseTypeKeyword(a, m.v); } else if (std.mem.eql(u8, m.k, "const")) { node.const_val = m.v; } else if (std.mem.eql(u8, m.k, "enum")) { if (m.v == .arr) node.enum_vals = m.v.arr; } else if (std.mem.eql(u8, m.k, "minimum")) { if (m.v == .num) node.minimum = m.v.num; } else if (std.mem.eql(u8, m.k, "maximum")) { if (m.v == .num) node.maximum = m.v.num; } else if (std.mem.eql(u8, m.k, "exclusiveMinimum")) { if (m.v == .num) node.exclusive_minimum = m.v.num; } else if (std.mem.eql(u8, m.k, "exclusiveMaximum")) { if (m.v == .num) node.exclusive_maximum = m.v.num; } else if (std.mem.eql(u8, m.k, "multipleOf")) { if (m.v == .num) node.multiple_of = m.v.num; } else if (std.mem.eql(u8, m.k, "minLength")) { if (m.v == .num) node.min_length = parseIntKeyword(m.v.num); } else if (std.mem.eql(u8, m.k, "maxLength")) { if (m.v == .num) node.max_length = parseIntKeyword(m.v.num); } else if (std.mem.eql(u8, m.k, "pattern")) { if (m.v == .str) { const re = try a.create(regex_mod.Regex); // A regex compile failure (unsupported/oversized/invalid // pattern) is a schema-compile error, not a raw error to // propagate. Report it through the `err` out-param like // every other schema-invalid failure (NQUAL-003). re.* = regex_mod.Regex.compile(m.v.str, a) catch |compile_err| { if (compile_err == error.OutOfMemory) return compile_err; err.* = .{ .code = .SchemaInvalid, .line = 1, .col = 1, .offset = 0 }; return error.JsonParse; }; node.pattern = re; } } else if (std.mem.eql(u8, m.k, "format")) { if (m.v == .str) node.format = try a.dupe(u8, m.v.str); } else if (std.mem.eql(u8, m.k, "minItems")) { if (m.v == .num) node.min_items = parseIntKeyword(m.v.num); } else if (std.mem.eql(u8, m.k, "maxItems")) { if (m.v == .num) node.max_items = parseIntKeyword(m.v.num); } else if (std.mem.eql(u8, m.k, "uniqueItems")) { if (m.v == .bool) node.unique_items = m.v.bool; } else if (std.mem.eql(u8, m.k, "items")) { const loc = try pointer.append(location, "items", a); node.items = try compileNode(a, m.v, loc, opts, err, sdepth + 1); } else if (std.mem.eql(u8, m.k, "prefixItems")) { if (m.v == .arr) { var prefix: std.ArrayList(*SchemaNode) = .empty; for (m.v.arr, 0..) |item, i| { const loc = try pointer.appendIndex(location, i, a); try prefix.append(a, try compileNode(a, item, loc, opts, err, sdepth + 1)); } node.prefix_items = try prefix.toOwnedSlice(a); } } else if (std.mem.eql(u8, m.k, "contains")) { const loc = try pointer.append(location, "contains", a); node.contains = try compileNode(a, m.v, loc, opts, err, sdepth + 1); } else if (std.mem.eql(u8, m.k, "minContains")) { if (m.v == .num) node.min_contains = parseIntKeyword(m.v.num); } else if (std.mem.eql(u8, m.k, "maxContains")) { if (m.v == .num) node.max_contains = parseIntKeyword(m.v.num); } else if (std.mem.eql(u8, m.k, "minProperties")) { if (m.v == .num) node.min_properties = parseIntKeyword(m.v.num); } else if (std.mem.eql(u8, m.k, "maxProperties")) { if (m.v == .num) node.max_properties = parseIntKeyword(m.v.num); } else if (std.mem.eql(u8, m.k, "required")) { if (m.v == .arr) { var req: std.ArrayList([]const u8) = .empty; for (m.v.arr) |item| { if (item == .str) try req.append(a, try a.dupe(u8, item.str)); } node.required = try req.toOwnedSlice(a); } } else if (std.mem.eql(u8, m.k, "properties")) { if (m.v == .obj) { var props = std.StringHashMap(*SchemaNode).init(a); for (m.v.obj) |prop| { const loc = try std.fmt.allocPrint(a, "{s}/properties/{s}", .{ location, prop.k }); try props.put(try a.dupe(u8, prop.k), try compileNode(a, prop.v, loc, opts, err, sdepth + 1)); } node.properties = props; } } else if (std.mem.eql(u8, m.k, "patternProperties")) { if (m.v == .obj) { var patterns: std.ArrayList(PatternProperty) = .empty; for (m.v.obj) |prop| { const re = try a.create(regex_mod.Regex); // Same as `pattern`: a compile failure on a // patternProperties key is a schema-compile error, // reported via `err` rather than propagated raw // (NQUAL-003). re.* = regex_mod.Regex.compile(prop.k, a) catch |compile_err| { if (compile_err == error.OutOfMemory) return compile_err; err.* = .{ .code = .SchemaInvalid, .line = 1, .col = 1, .offset = 0 }; return error.JsonParse; }; const loc = try pointer.append(location, prop.k, a); try patterns.append(a, .{ .pattern = re, .schema = try compileNode(a, prop.v, loc, opts, err, sdepth + 1), }); } node.pattern_properties = try patterns.toOwnedSlice(a); } } else if (std.mem.eql(u8, m.k, "additionalProperties")) { const loc = try pointer.append(location, "additionalProperties", a); node.additional_properties = try compileNode(a, m.v, loc, opts, err, sdepth + 1); } else if (std.mem.eql(u8, m.k, "propertyNames")) { const loc = try pointer.append(location, "propertyNames", a); node.property_names = try compileNode(a, m.v, loc, opts, err, sdepth + 1); } else if (std.mem.eql(u8, m.k, "dependentRequired")) { if (m.v == .obj) { var dep_req = std.StringHashMap([]const []const u8).init(a); for (m.v.obj) |dep| { if (dep.v == .arr) { var reqs: std.ArrayList([]const u8) = .empty; for (dep.v.arr) |r| { if (r == .str) try reqs.append(a, try a.dupe(u8, r.str)); } try dep_req.put(try a.dupe(u8, dep.k), try reqs.toOwnedSlice(a)); } } node.dependent_required = dep_req; } } else if (std.mem.eql(u8, m.k, "dependentSchemas")) { if (m.v == .obj) { var dep_sch = std.StringHashMap(*SchemaNode).init(a); for (m.v.obj) |dep| { const loc = try pointer.append(location, dep.k, a); try dep_sch.put(try a.dupe(u8, dep.k), try compileNode(a, dep.v, loc, opts, err, sdepth + 1)); } node.dependent_schemas = dep_sch; } } else if (std.mem.eql(u8, m.k, "allOf")) { if (m.v == .arr) { var schemas: std.ArrayList(*SchemaNode) = .empty; for (m.v.arr, 0..) |s, i| { const loc = try pointer.appendIndex(location, i, a); try schemas.append(a, try compileNode(a, s, loc, opts, err, sdepth + 1)); } node.all_of = try schemas.toOwnedSlice(a); } } else if (std.mem.eql(u8, m.k, "anyOf")) { if (m.v == .arr) { var schemas: std.ArrayList(*SchemaNode) = .empty; for (m.v.arr, 0..) |s, i| { const loc = try pointer.appendIndex(location, i, a); try schemas.append(a, try compileNode(a, s, loc, opts, err, sdepth + 1)); } node.any_of = try schemas.toOwnedSlice(a); } } else if (std.mem.eql(u8, m.k, "oneOf")) { if (m.v == .arr) { var schemas: std.ArrayList(*SchemaNode) = .empty; for (m.v.arr, 0..) |s, i| { const loc = try pointer.appendIndex(location, i, a); try schemas.append(a, try compileNode(a, s, loc, opts, err, sdepth + 1)); } node.one_of = try schemas.toOwnedSlice(a); } } else if (std.mem.eql(u8, m.k, "not")) { const loc = try pointer.append(location, "not", a); node.not = try compileNode(a, m.v, loc, opts, err, sdepth + 1); } else if (std.mem.eql(u8, m.k, "if")) { const loc = try pointer.append(location, "if", a); node.if_schema = try compileNode(a, m.v, loc, opts, err, sdepth + 1); } else if (std.mem.eql(u8, m.k, "then")) { const loc = try pointer.append(location, "then", a); node.then_schema = try compileNode(a, m.v, loc, opts, err, sdepth + 1); } else if (std.mem.eql(u8, m.k, "else")) { const loc = try pointer.append(location, "else", a); node.else_schema = try compileNode(a, m.v, loc, opts, err, sdepth + 1); } else if (std.mem.eql(u8, m.k, "unevaluatedItems")) { const loc = try pointer.append(location, "unevaluatedItems", a); node.unevaluated_items = try compileNode(a, m.v, loc, opts, err, sdepth + 1); } else if (std.mem.eql(u8, m.k, "unevaluatedProperties")) { const loc = try pointer.append(location, "unevaluatedProperties", a); node.unevaluated_properties = try compileNode(a, m.v, loc, opts, err, sdepth + 1); } else if (std.mem.eql(u8, m.k, "$defs")) { if (m.v == .obj) { var defs = std.StringHashMap(*SchemaNode).init(a); for (m.v.obj) |def| { const loc = try std.fmt.allocPrint(a, "{s}/$defs/{s}", .{ location, def.k }); try defs.put(try a.dupe(u8, def.k), try compileNode(a, def.v, loc, opts, err, sdepth + 1)); } node.defs = defs; } } } return node; }, else => { err.* = .{ .code = .SchemaInvalid, .line = 1, .col = 1, .offset = 0 }; return error.JsonParse; }, }}
/// Parse a JSON Schema numeric keyword that must be a non-negative integer/// (minItems, maxItems, minLength, maxLength, minProperties, maxProperties,/// minContains, maxContains).////// The value is a normalized `Number` (sign * digits * 10^exp10), so the/// magnitude implied by `exp10` MUST be honored: `1e2` is 100, not 1. A value/// that is negative, fractional, or larger than a u64 can hold is not a valid/// non-negative integer for these keywords; rather than silently coercing it to/// a wrong bound (the old behavior returned 0, turning the constraint into a/// no-op or an incorrect limit), we return `null` so the caller leaves the/// keyword unset and the constraint is simply not applied.fn parseIntKeyword(n: Number) ?u64 { // Negative values are not valid non-negative integers. if (n.sign < 0) { // The normalized zero is always +0, so a negative sign means a real // negative magnitude. return null; }
// Reject fractional values (e.g. 1.5): a non-integer is not a valid bound. if (!number_mod.isInteger(n)) return null;
// digits * 10^exp10, with exp10 >= -leadingFractionalZeros. Because the // value is an integer, any negative exp10 only cancels trailing zeros in // `digits`; build the integer magnitude with overflow checks. var value: u64 = std.fmt.parseInt(u64, n.digits, 10) catch return null;
var exp = n.exp10; // Negative exponent on an integer means `digits` has trailing zeros that // the exponent removes; divide them back out exactly. while (exp < 0) : (exp += 1) { // isInteger guarantees value % 10 == 0 here, but guard defensively. if (value % 10 != 0) return null; value /= 10; } // Positive exponent scales the magnitude up; do so with overflow checks. while (exp > 0) : (exp -= 1) { value = std.math.mul(u64, value, 10) catch return null; }
return value;}
fn parseTypeKeyword(a: std.mem.Allocator, v: Value) !?[]const []const u8 { switch (v) { .str => |s| { var types: std.ArrayList([]const u8) = .empty; try types.append(a, try a.dupe(u8, s)); return try types.toOwnedSlice(a); }, .arr => |arr| { var types: std.ArrayList([]const u8) = .empty; for (arr) |item| { if (item == .str) try types.append(a, try a.dupe(u8, item.str)); } return try types.toOwnedSlice(a); }, else => return null, }}
const ValidationContext = struct { allocator: std.mem.Allocator, errors: std.ArrayList(ValidationError), opts: SchemaOptions, schema: *const CompiledSchema, evaluated_props: std.StringHashMap(void), evaluated_items: std.AutoHashMap(usize, void), allocated_ptrs: std.ArrayList([]const u8), /// Cache for resolved $ref references - maps (ref, base_uri) to resolved node ref_cache: RefCache, /// Cache for resolved $dynamicRef references dynamic_ref_cache: RefCache,
/// Cache key combining ref string and base URI const RefCacheKey = struct { ref: []const u8, base_uri: []const u8,
pub fn hash(self: RefCacheKey) u64 { // fips-exempt: non-security — JSON-Schema $ref cache key hashing // (ref + base_uri); hashmap lookup only, not a secret. var hasher = std.hash.Wyhash.init(0); hasher.update(self.ref); hasher.update(&[_]u8{0}); // separator hasher.update(self.base_uri); return hasher.final(); }
pub fn eql(a: RefCacheKey, b: RefCacheKey) bool { return std.mem.eql(u8, a.ref, b.ref) and std.mem.eql(u8, a.base_uri, b.base_uri); } };
const RefCache = std.HashMap(RefCacheKey, ?*const SchemaNode, RefCacheContext, std.hash_map.default_max_load_percentage);
const RefCacheContext = struct { pub fn hash(_: RefCacheContext, key: RefCacheKey) u64 { return key.hash(); }
pub fn eql(_: RefCacheContext, a: RefCacheKey, b: RefCacheKey) bool { return a.eql(b); } };
fn init(a: std.mem.Allocator, sch: *const CompiledSchema, opts: SchemaOptions) ValidationContext { return .{ .allocator = a, .errors = .empty, .opts = opts, .schema = sch, .evaluated_props = std.StringHashMap(void).init(a), .evaluated_items = std.AutoHashMap(usize, void).init(a), .allocated_ptrs = .empty, .ref_cache = RefCache.init(a), .dynamic_ref_cache = RefCache.init(a), }; }
fn deinit(self: *ValidationContext) void { // Free error strings for (self.errors.items) |err| { self.allocator.free(err.instance_ptr); self.allocator.free(err.schema_ptr); self.allocator.free(err.keyword); self.allocator.free(err.message); } self.errors.deinit(self.allocator); self.evaluated_props.deinit(); self.evaluated_items.deinit(); for (self.allocated_ptrs.items) |ptr| { self.allocator.free(ptr); } self.allocated_ptrs.deinit(self.allocator); self.ref_cache.deinit(); self.dynamic_ref_cache.deinit(); }
/// Get cached $ref resolution or compute and cache it fn getCachedRef(self: *ValidationContext, ref: []const u8, base_uri: []const u8) !?*const SchemaNode { const key = RefCacheKey{ .ref = ref, .base_uri = base_uri }; if (self.ref_cache.get(key)) |cached| { return cached; }
// Not cached - resolve and cache const resolved = try resolveRef(self.allocator, self.schema, ref, base_uri); self.ref_cache.put(key, resolved) catch {}; return resolved; }
/// Get cached $dynamicRef resolution or compute and cache it fn getCachedDynamicRef(self: *ValidationContext, ref: []const u8, base_uri: []const u8) ?*const SchemaNode { const key = RefCacheKey{ .ref = ref, .base_uri = base_uri }; if (self.dynamic_ref_cache.get(key)) |cached| { return cached; }
// Not cached - resolve and cache const resolved = resolveDynamicRef(self.allocator, self.schema, ref, base_uri); self.dynamic_ref_cache.put(key, resolved) catch {}; return resolved; }
fn allocPtr(self: *ValidationContext, base: []const u8, key: []const u8) ![]const u8 { const ptr = try pointer.append(base, key, self.allocator); try self.allocated_ptrs.append(self.allocator, ptr); return ptr; }
fn allocIdxPtr(self: *ValidationContext, base: []const u8, idx: usize) ![]const u8 { const ptr = try pointer.appendIndex(base, idx, self.allocator); try self.allocated_ptrs.append(self.allocator, ptr); return ptr; }
fn addError(self: *ValidationContext, inst_ptr: []const u8, schema_ptr: []const u8, keyword: []const u8, message: []const u8) !void { if (self.errors.items.len >= self.opts.max_errors) return;
try self.errors.append(self.allocator, .{ .instance_ptr = try self.allocator.dupe(u8, inst_ptr), .schema_ptr = try self.allocator.dupe(u8, schema_ptr), .keyword = try self.allocator.dupe(u8, keyword), .message = try self.allocator.dupe(u8, message), }); }};
pub fn validate(a: std.mem.Allocator, sch: *const CompiledSchema, inst: Value, opts: SchemaOptions) !ValidationReport { var ctx = ValidationContext.init(a, sch, opts); errdefer ctx.deinit();
try validateNode(&ctx, inst, sch.root, "", 0, 0);
const errors = try ctx.errors.toOwnedSlice(a);
// Clean up ctx resources (except errors which is now owned by caller) ctx.evaluated_props.deinit(); ctx.evaluated_items.deinit(); for (ctx.allocated_ptrs.items) |ptr| { a.free(ptr); } ctx.allocated_ptrs.deinit(a); ctx.ref_cache.deinit(); ctx.dynamic_ref_cache.deinit();
// Sort errors deterministically std.mem.sort(ValidationError, errors, {}, struct { fn lessThan(_: void, lhs: ValidationError, rhs: ValidationError) bool { const ptr_cmp = std.mem.order(u8, lhs.instance_ptr, rhs.instance_ptr); if (ptr_cmp != .eq) return ptr_cmp == .lt; const sch_cmp = std.mem.order(u8, lhs.schema_ptr, rhs.schema_ptr); if (sch_cmp != .eq) return sch_cmp == .lt; return std.mem.order(u8, lhs.keyword, rhs.keyword) == .lt; } }.lessThan);
return .{ .ok = errors.len == 0, .errors = errors, };}
/// Resolve a $ref to a SchemaNodefn resolveRef(alloc: std.mem.Allocator, schema: *const CompiledSchema, ref: []const u8, current_base: []const u8) !?*const SchemaNode { if (ref.len == 0) return null;
// Fragment-only reference (#, #anchor, #/json/pointer) - resolve against current schema if (ref[0] == '#') { // Find the schema with the current base URI const base_node = if (current_base.len > 0) schema.id_registry.get(current_base) orelse schema.root else schema.root;
// Root reference if (ref.len == 1) return base_node;
// JSON pointer reference (# followed by /) if (ref[1] == '/') { const json_ptr = ref[1..]; return resolvePointer(base_node, json_ptr); }
// Anchor reference (# followed by name) const anchor = ref[1..]; // Try full URI form first if (current_base.len > 0) { const full_anchor = try std.fmt.allocPrint(alloc, "{s}#{s}", .{ current_base, anchor }); defer alloc.free(full_anchor); if (schema.anchors.get(full_anchor)) |node| return node; } return schema.anchors.get(anchor); }
// Try to resolve the ref as an absolute or relative URI var resolved_ref: []const u8 = ref; var allocated = false;
// If ref doesn't have a scheme, try resolving against current base if (std.mem.indexOf(u8, ref, ":") == null and current_base.len > 0) { // Relative ref - resolve against current base const parsed_ref = uri_mod.parse(ref) catch return null; const parsed_base = uri_mod.parse(current_base) catch return null; var resolved = uri_mod.resolve(alloc, parsed_base, parsed_ref) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return null, }; defer resolved.deinit(alloc); // Free every owned field (scheme, host, path, query, …).
var aw: std.Io.Writer.Allocating = .init(alloc); resolved.format(&aw.writer) catch { aw.deinit(); return error.OutOfMemory; }; resolved_ref = aw.toOwnedSlice() catch { aw.deinit(); return error.OutOfMemory; }; allocated = true; } defer if (allocated) alloc.free(resolved_ref);
// Check for fragment in the resolved ref if (std.mem.indexOf(u8, resolved_ref, "#")) |frag_pos| { const base_uri = resolved_ref[0..frag_pos]; const frag = resolved_ref[frag_pos + 1 ..];
// Look up base URI in id_registry if (schema.id_registry.get(base_uri)) |base_node| { if (frag.len == 0) { return base_node; } else if (frag[0] == '/') { // JSON pointer within the referenced schema return resolvePointer(base_node, frag); } else { // Anchor reference if (schema.anchors.get(resolved_ref)) |node| return node; return schema.anchors.get(frag); } }
// Try looking up the full ref in anchors return schema.anchors.get(resolved_ref); }
// No fragment - look up the full URI directly in id_registry if (schema.id_registry.get(resolved_ref)) |node| return node;
// Try looking up in anchors return schema.anchors.get(resolved_ref);}
/// Resolve a $dynamicRef to a SchemaNode/// Dynamic refs look up in dynamic_anchors instead of anchors/// Per JSON Schema 2020-12 spec, $dynamicRef resolves to the OUTERMOST $dynamicAnchor in dynamic scopefn resolveDynamicRef(alloc: std.mem.Allocator, schema: *const CompiledSchema, ref: []const u8, current_base: []const u8) ?*const SchemaNode { _ = alloc; _ = current_base; if (ref.len == 0) return null;
// $dynamicRef typically has a fragment-only form like "#meta" if (ref[0] == '#') { const anchor = ref[1..]; if (anchor.len == 0) return schema.root;
// For $dynamicRef, always use the plain anchor name (outermost wins per spec) // The outermost $dynamicAnchor is the first one registered, stored under plain name if (schema.dynamic_anchors.get(anchor)) |node| return node;
// Fall back to regular anchors return schema.anchors.get(anchor); }
// For non-fragment $dynamicRef (rare), look up in dynamic_anchors by URI if (std.mem.indexOf(u8, ref, "#")) |frag_pos| { const frag = ref[frag_pos + 1 ..]; if (frag.len > 0 and frag[0] != '/') { // Plain anchor fragment - use the plain name for outermost resolution if (schema.dynamic_anchors.get(frag)) |node| return node; return schema.anchors.get(frag); } }
// Try direct lookup if (schema.dynamic_anchors.get(ref)) |node| return node; return schema.id_registry.get(ref);}
/// Resolve a JSON pointer within a SchemaNode treefn resolvePointer(root: *const SchemaNode, ptr: []const u8) ?*const SchemaNode { if (ptr.len == 0) return root; if (ptr[0] != '/') return null;
var current: *const SchemaNode = root; var rest = ptr[1..]; // Skip leading /
// Handle edge case: path is just "/" meaning empty string segment if (rest.len == 0) { // This would be looking for key "" at root level - not valid for schema return null; }
while (true) { // Find next segment (empty segments are valid - they represent "" keys) const slash_pos = std.mem.indexOf(u8, rest, "/"); const segment = if (slash_pos) |pos| rest[0..pos] else rest; const remaining = if (slash_pos) |pos| rest[pos + 1 ..] else ""; const has_more = slash_pos != null;
// Decode JSON pointer escapes (~0 -> ~, ~1 -> /) var decoded_buf: [256]u8 = undefined; const decoded = decodePointerSegment(segment, &decoded_buf) orelse return null;
// Handle container segments that need to look at next segment for the key if (std.mem.eql(u8, decoded, "$defs")) { // $defs requires a next segment (the key name, which can be empty "") if (!has_more) return null;
const next_slash = std.mem.indexOf(u8, remaining, "/"); const def_name_seg = if (next_slash) |pos| remaining[0..pos] else remaining; var def_name_buf: [256]u8 = undefined; const def_name = decodePointerSegment(def_name_seg, &def_name_buf) orelse return null;
if (current.defs) |defs| { current = defs.get(def_name) orelse return null; } else { return null; }
// If there's more path after the key, continue; otherwise we're done if (next_slash) |pos| { rest = remaining[pos + 1 ..]; if (rest.len == 0) return current; // Path ended with / continue; } else { return current; } }
if (std.mem.eql(u8, decoded, "properties")) { if (!has_more) return null;
const next_slash = std.mem.indexOf(u8, remaining, "/"); const prop_name_seg = if (next_slash) |pos| remaining[0..pos] else remaining; var prop_name_buf: [256]u8 = undefined; const prop_name = decodePointerSegment(prop_name_seg, &prop_name_buf) orelse return null;
if (current.properties) |props| { current = props.get(prop_name) orelse return null; } else { return null; }
if (next_slash) |pos| { rest = remaining[pos + 1 ..]; if (rest.len == 0) return current; continue; } else { return current; } }
if (std.mem.eql(u8, decoded, "prefixItems")) { if (!has_more) return null;
const next_slash = std.mem.indexOf(u8, remaining, "/"); const idx_seg = if (next_slash) |pos| remaining[0..pos] else remaining; const idx = std.fmt.parseInt(usize, idx_seg, 10) catch return null;
if (current.prefix_items) |items| { if (idx >= items.len) return null; current = items[idx]; } else { return null; }
if (next_slash) |pos| { rest = remaining[pos + 1 ..]; if (rest.len == 0) return current; continue; } else { return current; } }
if (std.mem.eql(u8, decoded, "allOf") or std.mem.eql(u8, decoded, "anyOf") or std.mem.eql(u8, decoded, "oneOf")) { if (!has_more) return null;
const next_slash = std.mem.indexOf(u8, remaining, "/"); const idx_seg = if (next_slash) |pos| remaining[0..pos] else remaining; const idx = std.fmt.parseInt(usize, idx_seg, 10) catch return null;
const arr = if (std.mem.eql(u8, decoded, "allOf")) current.all_of else if (std.mem.eql(u8, decoded, "anyOf")) current.any_of else current.one_of;
if (arr) |a| { if (idx >= a.len) return null; current = a[idx]; } else { return null; }
if (next_slash) |pos| { rest = remaining[pos + 1 ..]; if (rest.len == 0) return current; continue; } else { return current; } }
// Direct schema node references current = navigateSchema(current, decoded) orelse return null;
// If no more segments, we're done if (!has_more) return current; rest = remaining; if (rest.len == 0) return current; }}
fn decodePointerSegment(segment: []const u8, buf: []u8) ?[]const u8 { var out_idx: usize = 0; var i: usize = 0;
while (i < segment.len and out_idx < buf.len) { if (i + 1 < segment.len and segment[i] == '~') { if (segment[i + 1] == '0') { buf[out_idx] = '~'; i += 2; } else if (segment[i + 1] == '1') { buf[out_idx] = '/'; i += 2; } else { buf[out_idx] = segment[i]; i += 1; } } else if (i + 2 < segment.len and segment[i] == '%') { // Handle percent-encoding (e.g., %25 -> %) const hex = segment[i + 1 ..][0..2]; buf[out_idx] = unicode.parseHex2(hex) orelse { buf[out_idx] = segment[i]; i += 1; out_idx += 1; continue; }; i += 3; } else { buf[out_idx] = segment[i]; i += 1; } out_idx += 1; }
return buf[0..out_idx];}
fn navigateSchema(node: *const SchemaNode, segment: []const u8) ?*const SchemaNode { // Direct schema node keyword references if (std.mem.eql(u8, segment, "items")) return node.items; if (std.mem.eql(u8, segment, "additionalProperties")) return node.additional_properties; if (std.mem.eql(u8, segment, "not")) return node.not; if (std.mem.eql(u8, segment, "if")) return node.if_schema; if (std.mem.eql(u8, segment, "then")) return node.then_schema; if (std.mem.eql(u8, segment, "else")) return node.else_schema; if (std.mem.eql(u8, segment, "contains")) return node.contains; if (std.mem.eql(u8, segment, "propertyNames")) return node.property_names; if (std.mem.eql(u8, segment, "unevaluatedItems")) return node.unevaluated_items; if (std.mem.eql(u8, segment, "unevaluatedProperties")) return node.unevaluated_properties;
return null;}
fn validateNode(ctx: *ValidationContext, inst: Value, node: *const SchemaNode, inst_ptr: []const u8, depth: u16, sdepth: u16) !void { // Defense-in-depth (NQUAL): bound the TOTAL structural recursion depth // (every nested validateNode call -- structural keywords AND $ref hops) // separately from the `$ref`-only `depth` counter below. A caller that // hand-builds a pathologically deep schema or instance tree could otherwise // overflow the native stack during validation. Like the $ref-depth case, // this FAILS CLOSED: it records a validation error (so `valid` becomes // false) rather than silently returning, which would be a fail-open bypass. // Parsed schemas/instances can never reach this (parser nesting cap is well // below `max_schema_depth`). if (sdepth > max_schema_depth) { try ctx.addError(inst_ptr, node.location, "maxDepth", "SchemaLimit: schema/instance structural depth exceeded max_schema_depth"); return; }
// ASR-021 / GAP-002: $ref-depth exceed must FAIL CLOSED on this enforcement // boundary. A bare `return` here reported the instance as VALID once a $ref // chain (e.g. a self-referential {"$ref":"#"}) exceeded max_ref_depth, which // is a fail-OPEN validation bypass. Instead, record a validation error so the // report's `valid` becomes false. The keyword/message surface the // RefDepthLimit code (json.zig ErrorCode.RefDepthLimit); ValidationError has // no dedicated code field, so the name is carried in the message string. if (depth > ctx.opts.max_ref_depth) { try ctx.addError(inst_ptr, node.location, "$ref", "RefDepthLimit: $ref depth exceeded max_ref_depth"); return; }
// Boolean schema if (node.is_bool_schema) { if (!node.bool_value) { try ctx.addError(inst_ptr, node.location, "false", "schema is false"); } return; }
// Handle $ref (using cached resolution) if (node.ref) |ref| { if (try ctx.getCachedRef(ref, node.base_uri)) |ref_node| { try validateNode(ctx, inst, ref_node, inst_ptr, depth + 1, sdepth + 1); } }
// Handle $dynamicRef (using cached resolution) if (node.dynamic_ref) |dref| { if (ctx.getCachedDynamicRef(dref, node.base_uri)) |ref_node| { try validateNode(ctx, inst, ref_node, inst_ptr, depth + 1, sdepth + 1); } }
// Type validation if (node.schema_type) |types| { var type_valid = false; for (types) |t| { if (matchType(inst, t)) { type_valid = true; break; } } if (!type_valid) { try ctx.addError(inst_ptr, node.location, "type", "type mismatch"); } }
// Const validation if (node.const_val) |cv| { if (!inst.eql(cv)) { try ctx.addError(inst_ptr, node.location, "const", "value mismatch"); } }
// Enum validation if (node.enum_vals) |ev| { var found = false; for (ev) |e| { if (inst.eql(e)) { found = true; break; } } if (!found) { try ctx.addError(inst_ptr, node.location, "enum", "not in enum"); } }
// Number validations if (inst == .num) { const num = inst.num;
if (node.minimum) |min| { if (number_mod.compare(num, min) < 0) { try ctx.addError(inst_ptr, node.location, "minimum", "must be >= minimum"); } }
if (node.maximum) |max| { if (number_mod.compare(num, max) > 0) { try ctx.addError(inst_ptr, node.location, "maximum", "exceeds maximum"); } }
if (node.exclusive_minimum) |min| { if (number_mod.compare(num, min) <= 0) { try ctx.addError(inst_ptr, node.location, "exclusiveMinimum", "must be > exclusiveMinimum"); } }
if (node.exclusive_maximum) |max| { if (number_mod.compare(num, max) >= 0) { try ctx.addError(inst_ptr, node.location, "exclusiveMaximum", "must be < exclusiveMaximum"); } }
if (node.multiple_of) |mult| { if (!number_mod.isMultipleOf(num, mult)) { try ctx.addError(inst_ptr, node.location, "multipleOf", "not a multiple"); } } }
// String validations if (inst == .str) { const str = inst.str;
// PERF-005: compute the UTF-8 code-point length at most once per string, // only when a length keyword is present. The previous code walked the // string twice when both minLength and maxLength were set. if (node.min_length != null or node.max_length != null) { const len = countUtf8CodePoints(str); if (node.min_length) |min| { if (len < min) { try ctx.addError(inst_ptr, node.location, "minLength", "string too short"); } } if (node.max_length) |max| { if (len > max) { try ctx.addError(inst_ptr, node.location, "maxLength", "string too long"); } } }
if (node.pattern) |pat| { const matches = pat.match(str, ctx.opts.regex_max_steps) catch false; if (!matches) { try ctx.addError(inst_ptr, node.location, "pattern", "does not match pattern"); } }
if (node.format) |fmt| { if (ctx.opts.format_assertion) { if (!validateFormat(str, fmt, ctx.opts)) { var msg_buf: [64]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "must match format {s}", .{fmt}) catch "invalid format"; try ctx.addError(inst_ptr, node.location, "format", msg); } } } }
// Array validations if (inst == .arr) { const arr = inst.arr;
if (node.min_items) |min| { if (arr.len < min) { try ctx.addError(inst_ptr, node.location, "minItems", "too few items"); } }
if (node.max_items) |max| { if (arr.len > max) { try ctx.addError(inst_ptr, node.location, "maxItems", "too many items"); } }
if (node.unique_items) { outer: for (arr, 0..) |item, i| { for (arr[i + 1 ..]) |other| { if (item.eql(other)) { try ctx.addError(inst_ptr, node.location, "uniqueItems", "items not unique"); break :outer; } } } }
// Prefix items var prefix_len: usize = 0; if (node.prefix_items) |prefix| { prefix_len = prefix.len; for (prefix, 0..) |schema, i| { if (i < arr.len) { const item_ptr = try ctx.allocIdxPtr(inst_ptr, i); try validateNode(ctx, arr[i], schema, item_ptr, depth, sdepth + 1); try ctx.evaluated_items.put(i, {}); } } }
// Items schema (only for items beyond prefix_len) if (node.items) |items_schema| { if (prefix_len < arr.len) { for (arr[prefix_len..], prefix_len..) |item, i| { const item_ptr = try ctx.allocIdxPtr(inst_ptr, i); try validateNode(ctx, item, items_schema, item_ptr, depth, sdepth + 1); try ctx.evaluated_items.put(i, {}); } } }
// Contains if (node.contains) |contains_schema| { var count: u64 = 0; for (arr, 0..) |item, i| { var sub_ctx = ValidationContext.init(ctx.allocator, ctx.schema, ctx.opts); defer sub_ctx.deinit(); try validateNode(&sub_ctx, item, contains_schema, "", depth, sdepth + 1); if (sub_ctx.errors.items.len == 0) { count += 1; try ctx.evaluated_items.put(i, {}); } }
const min_contains = node.min_contains orelse 1; if (count < min_contains) { try ctx.addError(inst_ptr, node.location, "contains", "too few matches"); } if (node.max_contains) |max| { if (count > max) { try ctx.addError(inst_ptr, node.location, "contains", "too many matches"); } } } }
// Object validations if (inst == .obj) { const obj = inst.obj;
if (node.min_properties) |min| { if (obj.len < min) { try ctx.addError(inst_ptr, node.location, "minProperties", "too few properties"); } }
if (node.max_properties) |max| { if (obj.len > max) { try ctx.addError(inst_ptr, node.location, "maxProperties", "too many properties"); } }
// Required if (node.required) |required| { for (required) |req| { var found = false; for (obj) |m| { if (std.mem.eql(u8, m.k, req)) { found = true; break; } } if (!found) { try ctx.addError(inst_ptr, node.location, "required", "missing required property"); } } }
// Properties if (node.properties) |properties| { for (obj) |m| { if (properties.get(m.k)) |prop_schema| { const prop_ptr = try ctx.allocPtr(inst_ptr, m.k); try validateNode(ctx, m.v, prop_schema, prop_ptr, depth, sdepth + 1); try ctx.evaluated_props.put(m.k, {}); } } }
// Pattern properties if (node.pattern_properties) |patterns| { for (obj) |m| { for (patterns) |pp| { const matches = pp.pattern.match(m.k, ctx.opts.regex_max_steps) catch false; if (matches) { const prop_ptr = try ctx.allocPtr(inst_ptr, m.k); try validateNode(ctx, m.v, pp.schema, prop_ptr, depth, sdepth + 1); try ctx.evaluated_props.put(m.k, {}); } } } }
// Additional properties if (node.additional_properties) |ap_schema| { for (obj) |m| { var covered = false;
if (node.properties) |props| { if (props.contains(m.k)) covered = true; }
if (!covered and node.pattern_properties != null) { for (node.pattern_properties.?) |pp| { if (pp.pattern.match(m.k, ctx.opts.regex_max_steps) catch false) { covered = true; break; } } }
if (!covered) { const prop_ptr = try ctx.allocPtr(inst_ptr, m.k); try validateNode(ctx, m.v, ap_schema, prop_ptr, depth, sdepth + 1); try ctx.evaluated_props.put(m.k, {}); } } }
// Property names if (node.property_names) |pn_schema| { for (obj) |m| { try validateNode(ctx, .{ .str = m.k }, pn_schema, inst_ptr, depth, sdepth + 1); } }
// Dependent required if (node.dependent_required) |dep_req| { for (obj) |m| { if (dep_req.get(m.k)) |reqs| { for (reqs) |req| { var found = false; for (obj) |m2| { if (std.mem.eql(u8, m2.k, req)) { found = true; break; } } if (!found) { try ctx.addError(inst_ptr, node.location, "dependentRequired", "missing dependent property"); } } } } }
// Dependent schemas if (node.dependent_schemas) |dep_sch| { for (obj) |m| { if (dep_sch.get(m.k)) |schema| { try validateNode(ctx, inst, schema, inst_ptr, depth, sdepth + 1); } } } }
// allOf if (node.all_of) |schemas| { for (schemas) |schema| { try validateNode(ctx, inst, schema, inst_ptr, depth, sdepth + 1); } }
// anyOf - must collect annotations from ALL matching branches if (node.any_of) |schemas| { var valid = false; for (schemas) |schema| { var sub_ctx = ValidationContext.init(ctx.allocator, ctx.schema, ctx.opts); defer sub_ctx.deinit(); try validateNode(&sub_ctx, inst, schema, inst_ptr, depth, sdepth + 1); if (sub_ctx.errors.items.len == 0) { valid = true; // Merge evaluated tracking from successful branch var prop_it = sub_ctx.evaluated_props.keyIterator(); while (prop_it.next()) |key| { try ctx.evaluated_props.put(key.*, {}); } var item_it = sub_ctx.evaluated_items.keyIterator(); while (item_it.next()) |key| { try ctx.evaluated_items.put(key.*, {}); } } } if (!valid) { try ctx.addError(inst_ptr, node.location, "anyOf", "no schema matched"); } }
// oneOf - must collect annotations from the single matching branch if (node.one_of) |schemas| { var valid_count: usize = 0; var matching_props = std.StringHashMap(void).init(ctx.allocator); defer matching_props.deinit(); var matching_items = std.AutoHashMap(usize, void).init(ctx.allocator); defer matching_items.deinit();
for (schemas) |schema| { var sub_ctx = ValidationContext.init(ctx.allocator, ctx.schema, ctx.opts); defer sub_ctx.deinit(); try validateNode(&sub_ctx, inst, schema, inst_ptr, depth, sdepth + 1); if (sub_ctx.errors.items.len == 0) { valid_count += 1; if (valid_count == 1) { // Save evaluated tracking from first (and hopefully only) match var prop_it = sub_ctx.evaluated_props.keyIterator(); while (prop_it.next()) |key| { try matching_props.put(key.*, {}); } var item_it = sub_ctx.evaluated_items.keyIterator(); while (item_it.next()) |key| { try matching_items.put(key.*, {}); } } } } if (valid_count == 1) { // Merge evaluated tracking from the single matching branch var prop_it = matching_props.keyIterator(); while (prop_it.next()) |key| { try ctx.evaluated_props.put(key.*, {}); } var item_it = matching_items.keyIterator(); while (item_it.next()) |key| { try ctx.evaluated_items.put(key.*, {}); } } else { try ctx.addError(inst_ptr, node.location, "oneOf", "must match exactly one"); } }
// not if (node.not) |not_schema| { var sub_ctx = ValidationContext.init(ctx.allocator, ctx.schema, ctx.opts); defer sub_ctx.deinit(); try validateNode(&sub_ctx, inst, not_schema, inst_ptr, depth, sdepth + 1); if (sub_ctx.errors.items.len == 0) { try ctx.addError(inst_ptr, node.location, "not", "must not match"); } }
// if/then/else - must collect annotations from if and the executed branch if (node.if_schema) |if_sch| { var sub_ctx = ValidationContext.init(ctx.allocator, ctx.schema, ctx.opts); defer sub_ctx.deinit(); try validateNode(&sub_ctx, inst, if_sch, inst_ptr, depth, sdepth + 1);
// Merge evaluated tracking from if branch (regardless of result) var prop_it = sub_ctx.evaluated_props.keyIterator(); while (prop_it.next()) |key| { try ctx.evaluated_props.put(key.*, {}); } var item_it = sub_ctx.evaluated_items.keyIterator(); while (item_it.next()) |key| { try ctx.evaluated_items.put(key.*, {}); }
if (sub_ctx.errors.items.len == 0) { if (node.then_schema) |then_sch| { try validateNode(ctx, inst, then_sch, inst_ptr, depth, sdepth + 1); } } else { if (node.else_schema) |else_sch| { try validateNode(ctx, inst, else_sch, inst_ptr, depth, sdepth + 1); } } }
// Unevaluated properties if (inst == .obj and node.unevaluated_properties != null) { const obj = inst.obj; for (obj) |m| { if (!ctx.evaluated_props.contains(m.k)) { const prop_ptr = try ctx.allocPtr(inst_ptr, m.k); try validateNode(ctx, m.v, node.unevaluated_properties.?, prop_ptr, depth, sdepth + 1); } } }
// Unevaluated items if (inst == .arr and node.unevaluated_items != null) { const arr = inst.arr; for (arr, 0..) |item, i| { if (!ctx.evaluated_items.contains(i)) { const item_ptr = try ctx.allocIdxPtr(inst_ptr, i); try validateNode(ctx, item, node.unevaluated_items.?, item_ptr, depth, sdepth + 1); } } }}
fn matchType(v: Value, t: []const u8) bool { if (std.mem.eql(u8, t, "null")) return v == .null; if (std.mem.eql(u8, t, "boolean")) return v == .bool; if (std.mem.eql(u8, t, "string")) return v == .str; if (std.mem.eql(u8, t, "array")) return v == .arr; if (std.mem.eql(u8, t, "object")) return v == .obj; if (std.mem.eql(u8, t, "number")) return v == .num; if (std.mem.eql(u8, t, "integer")) { return v == .num and number_mod.isInteger(v.num); } return false;}
/// Count UTF-8 code points in a string (for JSON Schema minLength/maxLength)fn countUtf8CodePoints(str: []const u8) u64 { var count: u64 = 0; var i: usize = 0; while (i < str.len) { const byte = str[i]; // Count number of leading 1 bits to determine code point size if (byte & 0x80 == 0) { // ASCII: 0xxxxxxx i += 1; } else if (byte & 0xE0 == 0xC0) { // 2-byte: 110xxxxx 10xxxxxx i += 2; } else if (byte & 0xF0 == 0xE0) { // 3-byte: 1110xxxx 10xxxxxx 10xxxxxx i += 3; } else if (byte & 0xF8 == 0xF0) { // 4-byte: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx i += 4; } else { // Invalid UTF-8 or continuation byte, skip i += 1; } count += 1; } return count;}
fn validateFormat(str: []const u8, fmt: []const u8, opts: SchemaOptions) bool { // Check custom format validators first if (opts.custom_formats) |custom| { for (custom) |validator| { if (std.mem.eql(u8, validator.name, fmt)) { return validator.validate(str); } } }
// Built-in format validators if (std.mem.eql(u8, fmt, "email")) { return std.mem.indexOf(u8, str, "@") != null; } if (std.mem.eql(u8, fmt, "uri")) { return std.mem.indexOf(u8, str, "://") != null; } if (std.mem.eql(u8, fmt, "date")) { if (str.len != 10) return false; return str[4] == '-' and str[7] == '-'; } if (std.mem.eql(u8, fmt, "date-time")) { return std.mem.indexOf(u8, str, "T") != null; } if (std.mem.eql(u8, fmt, "uuid")) { if (str.len != 36) return false; return str[8] == '-' and str[13] == '-' and str[18] == '-' and str[23] == '-'; } // Unknown formats pass return true;}
test "validate type string" { const allocator = std.testing.allocator;
const schema_json = "{\"type\":\"string\"}"; var err = json.ParseError.init(); var schema_doc = try json.parse(allocator, schema_json, .{}, &err); defer schema_doc.deinit();
var compiled = try compile(allocator, &schema_doc, .{}, null, &err); defer compiled.deinit();
// Valid const valid_json = "\"hello\""; var valid_doc = try json.parse(allocator, valid_json, .{}, &err); defer valid_doc.deinit();
const valid_result = try validate(allocator, &compiled, valid_doc.root, .{}); defer allocator.free(valid_result.errors); try std.testing.expect(valid_result.ok);
// Invalid const invalid_json = "42"; var invalid_doc = try json.parse(allocator, invalid_json, .{}, &err); defer invalid_doc.deinit();
const invalid_result = try validate(allocator, &compiled, invalid_doc.root, .{}); defer { for (invalid_result.errors) |e| { allocator.free(e.instance_ptr); allocator.free(e.schema_ptr); allocator.free(e.keyword); allocator.free(e.message); } allocator.free(invalid_result.errors); } try std.testing.expect(!invalid_result.ok);}
test "NQUAL-003: invalid regex pattern reports SchemaInvalid via err" { const allocator = std.testing.allocator;
// Alternation/grouping are now *supported* (the ECMA-262 engine is a Pike // NFA), so this test uses a genuinely invalid pattern — an unbalanced group // "(a" — to verify the schema compiler still surfaces a regex compile // failure through the `err` out-param as a SchemaInvalid parse error rather // than leaking the raw regex error with `err` left uninitialized. The // positive case (alternation actually validates) is covered by // "schema pattern alternation/grouping is supported" below and by the // co-located tests in regex_ecma262.zig. const schema_json = "{\"pattern\":\"(a\"}"; var err = json.ParseError.init(); var schema_doc = try json.parse(allocator, schema_json, .{}, &err); defer schema_doc.deinit();
const result = compile(allocator, &schema_doc, .{}, null, &err); try std.testing.expectError(error.JsonParse, result); try std.testing.expectEqual(json.ErrorCode.SchemaInvalid, err.code);}
test "schema pattern alternation/grouping is supported" { const allocator = std.testing.allocator;
const schema_json = "{\"pattern\":\"^(cat|dog)$\"}"; var err = json.ParseError.init(); var schema_doc = try json.parse(allocator, schema_json, .{}, &err); defer schema_doc.deinit();
var compiled = try compile(allocator, &schema_doc, .{}, null, &err); defer compiled.deinit();
const Case = struct { json: []const u8, ok: bool }; const cases = [_]Case{ .{ .json = "\"cat\"", .ok = true }, .{ .json = "\"dog\"", .ok = true }, .{ .json = "\"fish\"", .ok = false }, }; for (cases) |c| { var d = try json.parse(allocator, c.json, .{}, &err); defer d.deinit(); const r = try validate(allocator, &compiled, d.root, .{}); defer { for (r.errors) |e| { allocator.free(e.instance_ptr); allocator.free(e.schema_ptr); allocator.free(e.keyword); allocator.free(e.message); } allocator.free(r.errors); } try std.testing.expectEqual(c.ok, r.ok); }}
test "validate minimum" { const allocator = std.testing.allocator;
const schema_json = "{\"minimum\":10}"; var err = json.ParseError.init(); var schema_doc = try json.parse(allocator, schema_json, .{}, &err); defer schema_doc.deinit();
var compiled = try compile(allocator, &schema_doc, .{}, null, &err); defer compiled.deinit();
// Invalid - below minimum const invalid_json = "5"; var invalid_doc = try json.parse(allocator, invalid_json, .{}, &err); defer invalid_doc.deinit();
const result = try validate(allocator, &compiled, invalid_doc.root, .{}); defer { for (result.errors) |e| { allocator.free(e.instance_ptr); allocator.free(e.schema_ptr); allocator.free(e.keyword); allocator.free(e.message); } allocator.free(result.errors); } try std.testing.expect(!result.ok);}
test "custom format validator" { const allocator = std.testing.allocator;
// Custom format that requires strings to start with "TEST-" const test_format = struct { fn validate(str: []const u8) bool { return std.mem.startsWith(u8, str, "TEST-"); } }.validate;
const custom_formats = [_]json.FormatValidator{ .{ .name = "test-format", .validate = test_format }, };
const schema_json = \\{"type":"string","format":"test-format"} ; var err = json.ParseError.init(); var schema_doc = try json.parse(allocator, schema_json, .{}, &err); defer schema_doc.deinit();
var compiled = try compile(allocator, &schema_doc, .{}, null, &err); defer compiled.deinit();
// Valid - starts with TEST- { const valid_json = "\"TEST-123\""; var valid_doc = try json.parse(allocator, valid_json, .{}, &err); defer valid_doc.deinit();
const result = try validate(allocator, &compiled, valid_doc.root, .{ .custom_formats = &custom_formats, }); defer { for (result.errors) |e| { allocator.free(e.instance_ptr); allocator.free(e.schema_ptr); allocator.free(e.keyword); allocator.free(e.message); } allocator.free(result.errors); } try std.testing.expect(result.ok); }
// Invalid - doesn't start with TEST- { const invalid_json = "\"INVALID-123\""; var invalid_doc = try json.parse(allocator, invalid_json, .{}, &err); defer invalid_doc.deinit();
const result = try validate(allocator, &compiled, invalid_doc.root, .{ .custom_formats = &custom_formats, }); defer { for (result.errors) |e| { allocator.free(e.instance_ptr); allocator.free(e.schema_ptr); allocator.free(e.keyword); allocator.free(e.message); } allocator.free(result.errors); } try std.testing.expect(!result.ok); }}
/// Free the heap-allocated strings inside a ValidationReport's errors slice and/// the slice itself. Mirrors the cleanup boilerplate used by the tests above.fn freeReport(allocator: std.mem.Allocator, result: ValidationReport) void { for (result.errors) |e| { allocator.free(e.instance_ptr); allocator.free(e.schema_ptr); allocator.free(e.keyword); allocator.free(e.message); } allocator.free(result.errors);}
// ASR-021 / GAP-002 (validation bypass): a self-referential {"$ref":"#"} schema// that exceeds max_ref_depth must FAIL CLOSED. Before the fix, validateNode did a// bare `return` on depth exceed, so the instance was reported VALID (fail-open).// Now a RefDepthLimit validation error is recorded and the report is INVALID.test "ASR-021: self-referential \\$ref depth exceed reports invalid, not valid" { const allocator = std.testing.allocator;
const schema_json = "{\"$ref\":\"#\"}"; var err = json.ParseError.init(); var schema_doc = try json.parse(allocator, schema_json, .{}, &err); defer schema_doc.deinit();
var compiled = try compile(allocator, &schema_doc, .{}, null, &err); defer compiled.deinit();
const inst_json = "42"; var inst_doc = try json.parse(allocator, inst_json, .{}, &err); defer inst_doc.deinit();
const result = try validate(allocator, &compiled, inst_doc.root, .{}); defer freeReport(allocator, result);
// Must NOT silently pass; the infinite self-reference exceeds max_ref_depth. try std.testing.expect(!result.ok); try std.testing.expect(result.errors.len >= 1);
// The error must surface the RefDepthLimit code (carried in the message, // since ValidationError has no dedicated code field) and the $ref keyword. var saw_ref_depth = false; for (result.errors) |e| { if (std.mem.eql(u8, e.keyword, "$ref") and std.mem.indexOf(u8, e.message, "RefDepthLimit") != null) { saw_ref_depth = true; } } try std.testing.expect(saw_ref_depth);}
// ASR-021: with a small max_ref_depth the same bypass is reproduced quickly and// still fails closed (independent of the default depth of 64).test "ASR-021: \\$ref depth exceed fails closed with small max_ref_depth" { const allocator = std.testing.allocator;
const schema_json = "{\"$ref\":\"#\"}"; var err = json.ParseError.init(); var schema_doc = try json.parse(allocator, schema_json, .{}, &err); defer schema_doc.deinit();
var compiled = try compile(allocator, &schema_doc, .{}, null, &err); defer compiled.deinit();
const inst_json = "true"; var inst_doc = try json.parse(allocator, inst_json, .{}, &err); defer inst_doc.deinit();
const result = try validate(allocator, &compiled, inst_doc.root, .{ .max_ref_depth = 4 }); defer freeReport(allocator, result);
try std.testing.expect(!result.ok);}
// ASR-021 (regression guard): a normal, shallow $ref chain that resolves within// the depth limit must still validate correctly -- only the EXCEEDED case changed// from valid->invalid. Here $ref points at a $defs subschema and the instance is// validated against it (valid case passes, invalid case fails on `type`, NOT on// RefDepthLimit).test "ASR-021: shallow \\$ref chain within depth limit still validates" { const allocator = std.testing.allocator;
const schema_json = \\{"$defs":{"str":{"type":"string"}},"$ref":"#/$defs/str"} ; var err = json.ParseError.init(); var schema_doc = try json.parse(allocator, schema_json, .{}, &err); defer schema_doc.deinit();
var compiled = try compile(allocator, &schema_doc, .{}, null, &err); defer compiled.deinit();
// Valid: a string satisfies the referenced subschema. { const inst_json = "\"hello\""; var inst_doc = try json.parse(allocator, inst_json, .{}, &err); defer inst_doc.deinit();
const result = try validate(allocator, &compiled, inst_doc.root, .{}); defer freeReport(allocator, result); try std.testing.expect(result.ok); }
// Invalid: a number fails the referenced subschema -- on `type`, not depth. { const inst_json = "42"; var inst_doc = try json.parse(allocator, inst_json, .{}, &err); defer inst_doc.deinit();
const result = try validate(allocator, &compiled, inst_doc.root, .{}); defer freeReport(allocator, result); try std.testing.expect(!result.ok); for (result.errors) |e| { try std.testing.expect(std.mem.indexOf(u8, e.message, "RefDepthLimit") == null); } }}
// ASR-021 (regression guard): a recursive schema validating finite data that// terminates BELOW the depth limit must still pass. A linked-list node whose// `next` is either null or another node; the data nests only a few levels deep,// far under max_ref_depth, so no RefDepthLimit error is produced.test "ASR-021: finite recursive \\$ref data within depth limit validates" { const allocator = std.testing.allocator;
const schema_json = \\{"type":"object","properties":{"next":{"anyOf":[{"type":"null"},{"$ref":"#"}]}}} ; var err = json.ParseError.init(); var schema_doc = try json.parse(allocator, schema_json, .{}, &err); defer schema_doc.deinit();
var compiled = try compile(allocator, &schema_doc, .{}, null, &err); defer compiled.deinit();
// Three levels of nesting, terminating in null -- well within depth 64. const inst_json = \\{"next":{"next":{"next":null}}} ; var inst_doc = try json.parse(allocator, inst_json, .{}, &err); defer inst_doc.deinit();
const result = try validate(allocator, &compiled, inst_doc.root, .{}); defer freeReport(allocator, result); try std.testing.expect(result.ok);}
// QUAL-015 (correctness): integer keywords expressed with an exponent (e.g.// minItems:1e1 == 10) must honor exp10. Before the fix parseIntKeyword read only// `digits`, so 1e1 parsed as 1 -- a far weaker bound. Now it parses as 10.test "QUAL-015: minItems as 1e1 enforces a bound of 10, not 1" { const allocator = std.testing.allocator;
const schema_json = "{\"minItems\":1e1}"; var err = json.ParseError.init(); var schema_doc = try json.parse(allocator, schema_json, .{}, &err); defer schema_doc.deinit();
var compiled = try compile(allocator, &schema_doc, .{}, null, &err); defer compiled.deinit();
// 9 items: below the true bound of 10 -> must FAIL. { const inst_json = "[1,2,3,4,5,6,7,8,9]"; var inst_doc = try json.parse(allocator, inst_json, .{}, &err); defer inst_doc.deinit();
const result = try validate(allocator, &compiled, inst_doc.root, .{}); defer freeReport(allocator, result); try std.testing.expect(!result.ok); }
// 10 items: exactly at the bound -> must PASS. { const inst_json = "[1,2,3,4,5,6,7,8,9,10]"; var inst_doc = try json.parse(allocator, inst_json, .{}, &err); defer inst_doc.deinit();
const result = try validate(allocator, &compiled, inst_doc.root, .{}); defer freeReport(allocator, result); try std.testing.expect(result.ok); }}
// QUAL-015: maxLength as 1e1 (==10) enforced on UTF-8 code points. A 10-char// string passes; an 11-char string fails. Before the fix the bound was 1.test "QUAL-015: maxLength as 1e1 enforces a bound of 10" { const allocator = std.testing.allocator;
const schema_json = "{\"type\":\"string\",\"maxLength\":1e1}"; var err = json.ParseError.init(); var schema_doc = try json.parse(allocator, schema_json, .{}, &err); defer schema_doc.deinit();
var compiled = try compile(allocator, &schema_doc, .{}, null, &err); defer compiled.deinit();
// Exactly 10 characters -> PASS. { const inst_json = "\"0123456789\""; var inst_doc = try json.parse(allocator, inst_json, .{}, &err); defer inst_doc.deinit();
const result = try validate(allocator, &compiled, inst_doc.root, .{}); defer freeReport(allocator, result); try std.testing.expect(result.ok); }
// 11 characters -> FAIL. { const inst_json = "\"0123456789A\""; var inst_doc = try json.parse(allocator, inst_json, .{}, &err); defer inst_doc.deinit();
const result = try validate(allocator, &compiled, inst_doc.root, .{}); defer freeReport(allocator, result); try std.testing.expect(!result.ok); }}
// QUAL-015: minLength as 1e1 (==10) must reject strings shorter than 10 and// accept those at/above the bound.test "QUAL-015: minLength as 1e1 enforces a bound of 10" { const allocator = std.testing.allocator;
const schema_json = "{\"type\":\"string\",\"minLength\":1e1}"; var err = json.ParseError.init(); var schema_doc = try json.parse(allocator, schema_json, .{}, &err); defer schema_doc.deinit();
var compiled = try compile(allocator, &schema_doc, .{}, null, &err); defer compiled.deinit();
// 9 characters -> FAIL. { const inst_json = "\"012345678\""; var inst_doc = try json.parse(allocator, inst_json, .{}, &err); defer inst_doc.deinit();
const result = try validate(allocator, &compiled, inst_doc.root, .{}); defer freeReport(allocator, result); try std.testing.expect(!result.ok); }
// 10 characters -> PASS. { const inst_json = "\"0123456789\""; var inst_doc = try json.parse(allocator, inst_json, .{}, &err); defer inst_doc.deinit();
const result = try validate(allocator, &compiled, inst_doc.root, .{}); defer freeReport(allocator, result); try std.testing.expect(result.ok); }}
// QUAL-015: a fractional integer keyword (e.g. minItems:1.5) is not a valid// non-negative integer. parseIntKeyword returns null so the keyword is left// unset (constraint not applied) rather than silently becoming a wrong bound.test "QUAL-015: fractional integer keyword is ignored (not a wrong bound)" { const allocator = std.testing.allocator;
const schema_json = "{\"minItems\":1.5}"; var err = json.ParseError.init(); var schema_doc = try json.parse(allocator, schema_json, .{}, &err); defer schema_doc.deinit();
var compiled = try compile(allocator, &schema_doc, .{}, null, &err); defer compiled.deinit();
// The keyword is unset, so even an empty array validates (no false bound). try std.testing.expect(compiled.root.min_items == null);
const inst_json = "[]"; var inst_doc = try json.parse(allocator, inst_json, .{}, &err); defer inst_doc.deinit();
const result = try validate(allocator, &compiled, inst_doc.root, .{}); defer freeReport(allocator, result); try std.testing.expect(result.ok);}
// QUAL-015: a plain (no-exponent) integer keyword still parses exactly as before.// Guards against regressing the common path.test "QUAL-015: plain integer keyword unchanged" { const allocator = std.testing.allocator;
const schema_json = "{\"minItems\":3,\"maxItems\":5}"; var err = json.ParseError.init(); var schema_doc = try json.parse(allocator, schema_json, .{}, &err); defer schema_doc.deinit();
var compiled = try compile(allocator, &schema_doc, .{}, null, &err); defer compiled.deinit();
try std.testing.expectEqual(@as(?u64, 3), compiled.root.min_items); try std.testing.expectEqual(@as(?u64, 5), compiled.root.max_items);}
// =============================================================================// NQUAL: structural recursion cap (defense-in-depth)//// `compileNode` / `validateNode` recurse over structural keywords// (`properties`/`items`/`allOf`/...). The existing `depth` param only bounds// `$ref` traversal, so a caller that hand-builds a pathologically deep schema// `Value` tree (bypassing the parser's nesting cap) could overflow the native// stack. The structural cap (`max_schema_depth`) makes such input fail cleanly:// a compile error at compile time, a fail-closed validation error at validate// time. These tests build trees DEEPER than the cap with the leak-detecting// testing allocator (and free them themselves) to prove no crash and no leak.// =============================================================================
/// Build a `levels`-deep nested `{"properties":{"x": <deeper>}}` schema Value/// tree, all strings/containers duped from `allocator`. Innermost value is an/// empty object schema `{}`. Each nesting level costs exactly one extra/// `compileNode` recursion. Free with `freeSchemaValue(allocator, ...)`.fn buildDeepPropertiesSchema(allocator: std.mem.Allocator, levels: usize) !Value { var current: Value = .{ .obj = try allocator.alloc(Member, 0) }; var i: usize = 0; while (i < levels) : (i += 1) { const inner = try allocator.alloc(Member, 1); inner[0] = .{ .k = try allocator.dupe(u8, "x"), .v = current }; const outer = try allocator.alloc(Member, 1); outer[0] = .{ .k = try allocator.dupe(u8, "properties"), .v = .{ .obj = inner } }; current = .{ .obj = outer }; } return current;}
/// Recursively free a hand-built schema Value tree (mirror of the builders/// above): frees duped keys, arrays, and object member slices.fn freeSchemaValue(allocator: std.mem.Allocator, v: Value) void { switch (v) { .str => |s| allocator.free(s), .arr => |arr| { for (arr) |item| freeSchemaValue(allocator, item); allocator.free(arr); }, .obj => |obj| { for (obj) |m| { allocator.free(m.k); freeSchemaValue(allocator, m.v); } allocator.free(obj); }, else => {}, }}
test "NQUAL: compile of schema deeper than max_schema_depth returns SchemaLimit (no crash)" { const allocator = std.testing.allocator;
// Deeper than the structural cap so compileNode trips the guard mid-recursion. const deep_root = try buildDeepPropertiesSchema(allocator, max_schema_depth + 50); defer freeSchemaValue(allocator, deep_root);
// Wrap in a Document with a throwaway arena (compile doesn't touch the // document arena; the root Value is the only thing read). var doc = json.Document{ .arena = std.heap.ArenaAllocator.init(allocator), .root = deep_root, }; defer doc.arena.deinit();
var err = json.ParseError.init(); const result = compile(allocator, &doc, .{}, null, &err); try std.testing.expectError(error.JsonParse, result); try std.testing.expectEqual(json.ErrorCode.SchemaLimit, err.code);}
test "NQUAL: schema within max_schema_depth compiles and validates normally" { const allocator = std.testing.allocator;
// Comfortably within the cap: must compile AND validate cleanly. This proves // the cap never affects legitimate (even fairly deep) schemas. const deep_root = try buildDeepPropertiesSchema(allocator, 100); defer freeSchemaValue(allocator, deep_root);
var doc = json.Document{ .arena = std.heap.ArenaAllocator.init(allocator), .root = deep_root, }; defer doc.arena.deinit();
var err = json.ParseError.init(); var compiled = try compile(allocator, &doc, .{}, null, &err); defer compiled.deinit();
// A matching nested instance validates without error. const inst_json = "{\"x\":{\"x\":{\"x\":{}}}}"; var inst_doc = try json.parse(allocator, inst_json, .{}, &err); defer inst_doc.deinit();
const result = try validate(allocator, &compiled, inst_doc.root, .{}); defer freeReport(allocator, result); try std.testing.expect(result.ok);}
test "NQUAL: validation structural depth over max_schema_depth fails closed (no crash)" { const allocator = std.testing.allocator;
// A self-referential schema `{"properties":{"x":{"$ref":"#"}}}` compiles // shallow (a couple of nodes) but validates as deep as the *instance*. Per // instance level the validator does a `properties` descent (sdepth+1) AND a // `$ref` hop (sdepth+1, depth+1) -- so the STRUCTURAL counter (sdepth) grows // ~2x as fast as the `$ref` counter (depth). With `max_ref_depth` raised well // above the structural cap, the structural guard (`sdepth > max_schema_depth`) // trips FIRST, exercising the new fail-closed branch independently of the // pre-existing ref-depth guard. A deep instance drives it past the cap. const schema_json = "{\"properties\":{\"x\":{\"$ref\":\"#\"}}}"; var err = json.ParseError.init(); var schema_doc = try json.parse(allocator, schema_json, .{}, &err); defer schema_doc.deinit();
var compiled = try compile(allocator, &schema_doc, .{}, null, &err); defer compiled.deinit();
// Build a deep `{"x":{"x":{...}}}` instance: ~2 structural recursions per // level, so > max_schema_depth/2 levels guarantees sdepth exceeds the cap. const inst_root = try buildDeepXInstance(allocator, max_schema_depth); defer freeSchemaValue(allocator, inst_root);
// Raise the ref-depth cap above the structural cap so the structural guard // is the one that trips. const opts = SchemaOptions{ .max_ref_depth = max_schema_depth * 4, .max_errors = 8 }; const result = try validate(allocator, &compiled, inst_root, opts); defer freeReport(allocator, result);
// Fail-closed: the report MUST be invalid (not silently valid), and the // SchemaLimit structural-depth error MUST be present. try std.testing.expect(!result.ok); var found = false; for (result.errors) |e| { if (std.mem.indexOf(u8, e.message, "SchemaLimit") != null) found = true; } try std.testing.expect(found);}
/// Build a `levels`-deep nested `{"x":{"x": ... {"x": null} ... }}` instance/// Value tree. Free with `freeSchemaValue(allocator, ...)`.fn buildDeepXInstance(allocator: std.mem.Allocator, levels: usize) !Value { var current: Value = .null; var i: usize = 0; while (i < levels) : (i += 1) { const members = try allocator.alloc(Member, 1); members[0] = .{ .k = try allocator.dupe(u8, "x"), .v = current }; current = .{ .obj = members }; } return current;}
test "fips-exempt annotation present on schema ref-cache Wyhash site" { const src = @embedFile("schema.zig"); try std.testing.expect(std.mem.indexOf(u8, src, "fips-exempt: non-security — JSON-Schema $ref cache key") != null);}
test "SONNY-2768: pointer %XX decoding takes exactly two hex digits" { var buf: [16]u8 = undefined; try std.testing.expectEqualStrings("a%b", decodePointerSegment("a%25b", &buf).?); try std.testing.expectEqualStrings("aJb", decodePointerSegment("a%4ab", &buf).?); // A sign or separator is not a hex digit, so the '%' stays literal. try std.testing.expectEqualStrings("a%+1b", decodePointerSegment("a%+1b", &buf).?); try std.testing.expectEqualStrings("a%_1b", decodePointerSegment("a%_1b", &buf).?); try std.testing.expectEqualStrings("a%-0b", decodePointerSegment("a%-0b", &buf).?);}
test "schema regex compilation preserves allocation failure" { const allocator = std.testing.allocator; for ([_][]const u8{ "{\"type\":\"string\",\"pattern\":\"^[a-z]+$\"}", "{\"type\":\"object\",\"patternProperties\":{\"^[a-z]+$\":{\"type\":\"integer\"}}}" }) |text| { var parse_error = ParseError.init(); var document = try json.parse(allocator, text, .{}, &parse_error); defer document.deinit(); var completed = false; for (0..1024) |failure| { var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); var failing = std.testing.FailingAllocator.init(arena.allocator(), .{ .fail_index = failure }); const outcome = compileNode(failing.allocator(), document.root, "", .{}, &parse_error, 0); if (failing.has_induced_failure) { try std.testing.expectError(error.OutOfMemory, outcome); } else { _ = try outcome; completed = true; break; } } try std.testing.expect(completed); } var parse_error = ParseError.init(); var invalid = try json.parse(allocator, "{\"pattern\":\"(a\"}", .{}, &parse_error); defer invalid.deinit(); try std.testing.expectError(error.JsonParse, compile(allocator, &invalid, .{}, null, &parse_error)); try std.testing.expectEqual(ErrorCode.SchemaInvalid, parse_error.code);}
test "schema anchor resolution allocation failure cannot skip validation" { const allocator = std.testing.allocator; var parse_error = ParseError.init(); var document = try json.parse(allocator, "{\"$id\":\"https://example.test/schema\",\"$defs\":{\"name\":{\"$anchor\":\"name\",\"type\":\"string\"}},\"$ref\":\"#name\"}", .{}, &parse_error); defer document.deinit(); var compiled = try compile(allocator, &document, .{}, null, &parse_error); defer compiled.deinit(); var exhausted = false; for (0..1024) |failure| { var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); var failing = std.testing.FailingAllocator.init(arena.allocator(), .{ .fail_index = failure }); if (validate(failing.allocator(), &compiled, .{ .bool = false }, .{})) |report| { try std.testing.expect(!report.ok); } else |err| { try std.testing.expectEqual(error.OutOfMemory, err); try std.testing.expect(failing.has_induced_failure); } if (!failing.has_induced_failure) { exhausted = true; break; } } try std.testing.expect(exhausted);}
test "schema relative identifier allocation failure cannot change its resolved URI" { const Once = struct { fn alloc(ctx: *anyopaque, len: usize, alignment: std.mem.Alignment, ret_addr: usize) ?[*]u8 { const failing: *std.testing.FailingAllocator = @ptrCast(@alignCast(ctx)); const result = failing.allocator().rawAlloc(len, alignment, ret_addr); if (failing.has_induced_failure) failing.fail_index = std.math.maxInt(usize); return result; } }; var exhausted = false; for (0..1024) |failure| { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); var failing = std.testing.FailingAllocator.init(arena.allocator(), .{ .fail_index = failure }); var allocator = failing.allocator(); var vtable = allocator.vtable.*; vtable.alloc = Once.alloc; allocator.vtable = &vtable; const outcome = resolveIdUri(allocator, "https://example.test/base/", "child"); if (failing.has_induced_failure) { try std.testing.expectError(error.OutOfMemory, outcome); } else { try std.testing.expectEqualStrings("https://example.test/base/child", try outcome); exhausted = true; break; } } try std.testing.expect(exhausted);}
fn expectCombinatorAllocationBehavior(schema_text: []const u8, instance_text: []const u8, expected: bool) !void { const Once = struct { fn alloc(ctx: *anyopaque, len: usize, alignment: std.mem.Alignment, ret_addr: usize) ?[*]u8 { const failing: *std.testing.FailingAllocator = @ptrCast(@alignCast(ctx)); const result = failing.allocator().rawAlloc(len, alignment, ret_addr); if (failing.has_induced_failure) failing.fail_index = std.math.maxInt(usize); return result; } }; const a = std.testing.allocator; var parse_error = ParseError.init(); var document = try json.parse(a, schema_text, .{}, &parse_error); defer document.deinit(); var instance = try json.parse(a, instance_text, .{}, &parse_error); defer instance.deinit(); var compiled = try compile(a, &document, .{}, null, &parse_error); defer compiled.deinit(); { var healthy = try validate(a, &compiled, instance.root, .{}); defer healthy.deinit(a); if (healthy.ok != expected) return error.HealthyControlMismatch; } for (0..1024) |failure| { var arena = std.heap.ArenaAllocator.init(a); defer arena.deinit(); var failing = std.testing.FailingAllocator.init(arena.allocator(), .{ .fail_index = failure }); var allocator = failing.allocator(); var vtable = allocator.vtable.*; vtable.alloc = Once.alloc; allocator.vtable = &vtable; if (validate(allocator, &compiled, instance.root, .{})) |report| { try std.testing.expectEqual(expected, report.ok); } else |err| { try std.testing.expectEqual(error.OutOfMemory, err); try std.testing.expect(failing.has_induced_failure); } if (!failing.has_induced_failure) return; } return error.FailureSweepDidNotComplete;}
test "schema contains allocation failure cannot change validation" { try expectCombinatorAllocationBehavior("{\"contains\":{\"properties\":{\"x\":{\"type\":\"string\"}}},\"maxContains\":1}", "[{\"x\":\"ok\"},{\"x\":\"ok\"}]", false);}
test "schema anyOf allocation failure cannot change validation" { try expectCombinatorAllocationBehavior("{\"anyOf\":[{\"properties\":{\"x\":{\"type\":\"string\"}}}]}", "{\"x\":\"ok\"}", true);}
test "schema oneOf allocation failure cannot change validation" { try expectCombinatorAllocationBehavior("{\"oneOf\":[{\"properties\":{\"x\":{\"type\":\"string\"}}},{\"properties\":{\"x\":{\"type\":\"string\"}}}]}", "{\"x\":\"ok\"}", false);}
test "schema not allocation failure cannot change validation" { try expectCombinatorAllocationBehavior("{\"not\":{\"type\":\"string\"}}", "false", true);}
test "schema conditional allocation failure cannot change validation" { try expectCombinatorAllocationBehavior("{\"if\":{\"type\":\"string\"},\"then\":false,\"else\":true}", "false", true);}