Something went wrong. Try again.
fork of gitlab.com/devnw/zig/json for zig 0.17
Something went wrong. Try again.
99 kB · 2481 lines
Zig
at main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482// SPDX-License-Identifier: Apache-2.0// Copyright (c) 2024-2025 CodePros
//! JSON-LD (JSON Linked Data) Support//!//! Implements JSON-LD 1.1 specification for representing Linked Data in JSON.//! Provides context expansion, compaction, flattening, and framing operations.//!//! Specification: https://www.w3.org/TR/json-ld11///!//! Features://! - Context processing and expansion//! - IRI expansion and compaction//! - Node object flattening//! - Framing for data extraction//! - N-Quads serialization
const std = @import("std");const json = @import("json.zig");
/// JSON-LD keywordspub const Keywords = struct { pub const context = "@context"; pub const id = "@id"; pub const type_kw = "@type"; pub const value = "@value"; pub const language = "@language"; pub const container = "@container"; pub const list = "@list"; pub const set = "@set"; pub const index = "@index"; pub const reverse = "@reverse"; pub const graph = "@graph"; pub const vocab = "@vocab"; pub const base = "@base"; pub const version = "@version"; pub const import_kw = "@import"; pub const propagate = "@propagate"; pub const protected = "@protected"; pub const direction = "@direction"; pub const json_kw = "@json"; pub const none = "@none"; pub const included = "@included"; pub const nest = "@nest"; pub const prefix = "@prefix";
pub fn isKeyword(term: []const u8) bool { return term.len > 0 and term[0] == '@'; }
pub fn isValidKeyword(term: []const u8) bool { if (!isKeyword(term)) return false; const keywords = [_][]const u8{ context, id, type_kw, value, language, container, list, set, index, reverse, graph, vocab, base, version, import_kw, propagate, protected, direction, json_kw, none, included, nest, prefix, }; for (keywords) |kw| { if (std.mem.eql(u8, term, kw)) return true; } return false; }};
/// IRI typespub const IriType = enum { /// Absolute IRI (e.g., "http://example.org/") absolute, /// Compact IRI (e.g., "ex:name") compact, /// Relative IRI reference relative, /// Blank node identifier (e.g., "_:b0") blank, /// Keyword (e.g., "@type") keyword,};
/// Determine the type of an IRIpub fn getIriType(iri: []const u8) IriType { if (iri.len == 0) return .relative;
// Check for keyword if (iri[0] == '@') return .keyword;
// Check for blank node if (iri.len >= 2 and iri[0] == '_' and iri[1] == ':') return .blank;
// Check for absolute IRI (contains "://") if (std.mem.indexOf(u8, iri, "://")) |_| return .absolute;
// Check for compact IRI (contains ":" but not at start) if (std.mem.indexOf(u8, iri, ":")) |pos| { if (pos > 0 and pos < iri.len - 1) return .compact; }
return .relative;}
/// Errors that JSON-LD processing can return.////// These supplement the allocator errors (`error.OutOfMemory`) that the/// recursive algorithms already propagate.pub const Error = error{ /// A document, value tree, or context nesting exceeded `Options.max_depth`. /// Raised to bound stack usage on untrusted, caller-built `Value` trees /// (which are NOT guaranteed to respect the core parser's 256-deep limit). MaxDepthExceeded, /// A remote `@context` URL was (directly or transitively) re-entered while /// it was still being resolved, i.e. a context-resolution cycle. RecursiveContext, /// A URL-valued `@context` was encountered but no document loader is /// configured (or the configured loader returned null for that URL), so the /// context cannot be resolved. Raised instead of silently producing output /// that ignores the referenced context. ContextNotLoaded, /// An `@import` context entry was encountered but could not be resolved /// (no document loader configured, or the loader returned null). Raised /// instead of silently dropping the `@import`. ContextImportFailed,};
/// JSON-LD processing optionspub const Options = struct { /// Base IRI for relative IRI resolution base: ?[]const u8 = null, /// Compact arrays with single elements compact_arrays: bool = true, /// Compact IRIs using prefixes compact_iris: bool = true, /// Document loader for remote contexts document_loader: ?*const fn ([]const u8) ?json.Value = null, /// Maximum nesting depth for recursive processing of caller-built `Value` /// trees and `@context` chains. Bounds stack usage on untrusted input so a /// deeply nested document returns `error.MaxDepthExceeded` instead of /// overflowing the stack. The default matches the core parser's nesting cap. max_depth: u32 = 256, /// Processing mode (1.0 or 1.1) processing_mode: ProcessingMode = .@"1.1", /// Produce generalized RDF produce_generalized_rdf: bool = false, /// Frame expansion mode frame_expansion: bool = false, /// Use native types for values use_native_types: bool = false, /// Use @container: @set for repeated properties use_rdf_type: bool = false,
pub const ProcessingMode = enum { @"1.0", @"1.1", };};
// =============================================================================// Remote Context Loading// =============================================================================
/// Remote context loader with caching support/// Provides a framework for loading JSON-LD contexts from URLspub const RemoteContextLoader = struct { allocator: std.mem.Allocator, /// Cache of loaded contexts cache: std.StringHashMap(CacheEntry), /// User-provided HTTP fetch function fetch_fn: ?*const fn ([]const u8) FetchError![]const u8, /// Maximum cache entries max_cache_size: usize, /// Cache hit/miss statistics cache_hits: usize, cache_misses: usize,
const Self = @This();
pub const FetchError = error{ NetworkError, InvalidUrl, NotFound, Timeout, TooManyRedirects, };
const CacheEntry = struct { context: json.Value, url: []const u8, };
/// Well-known JSON-LD context URLs pub const WellKnown = struct { pub const schema_org = "https://schema.org/"; pub const activitystreams = "https://www.w3.org/ns/activitystreams"; pub const security_v1 = "https://w3id.org/security/v1"; pub const did_v1 = "https://www.w3.org/ns/did/v1"; };
pub fn init(allocator: std.mem.Allocator) Self { return .{ .allocator = allocator, .cache = std.StringHashMap(CacheEntry).init(allocator), .fetch_fn = null, .max_cache_size = 100, .cache_hits = 0, .cache_misses = 0, }; }
pub fn deinit(self: *Self) void { var iter = self.cache.iterator(); while (iter.next()) |entry| { self.allocator.free(entry.key_ptr.*); freeValue(self.allocator, entry.value_ptr.context); } self.cache.deinit(); }
/// Set the HTTP fetch function for loading remote contexts pub fn setFetchFunction(self: *Self, fetch: *const fn ([]const u8) FetchError![]const u8) void { self.fetch_fn = fetch; }
/// Load a context from a URL (with caching) pub fn loadContext(self: *Self, url: []const u8) ?json.Value { // Check cache first if (self.cache.get(url)) |entry| { self.cache_hits += 1; return entry.context; }
self.cache_misses += 1;
// Try to fetch the context if (self.fetch_fn) |fetch| { const content = fetch(url) catch return null; defer self.allocator.free(content);
// Parse the JSON content var parse_err: json.ParseError = undefined; const doc = json.parse(self.allocator, content, .{}, &parse_err) catch return null; const parsed = doc.root;
// Cache the result const url_copy = self.allocator.dupe(u8, url) catch { freeValue(self.allocator, parsed); return null; };
// Evict oldest entry if cache is full if (self.cache.count() >= self.max_cache_size) { // Simple eviction: remove first entry. // // NEW-005: capture the key, stop using the iterator, then // `fetchRemove` BEFORE freeing anything. The previous code freed // the key and then called `remove(first.key_ptr.*)`, which hashes // and compares the just-freed key pointer -- a use-after-free // read. `fetchRemove` returns the removed entry while the key is // still valid; only then do we free the owned key and value. var iter = self.cache.iterator(); if (iter.next()) |first| { const key = first.key_ptr.*; if (self.cache.fetchRemove(key)) |kv| { freeValue(self.allocator, kv.value.context); self.allocator.free(kv.key); } } }
self.cache.put(url_copy, .{ .context = parsed, .url = url_copy }) catch { self.allocator.free(url_copy); freeValue(self.allocator, parsed); return null; };
return parsed; }
return null; }
/// Pre-cache a context for a given URL pub fn preloadContext(self: *Self, url: []const u8, context: json.Value) !void { const url_copy = try self.allocator.dupe(u8, url); errdefer self.allocator.free(url_copy);
try self.cache.put(url_copy, .{ .context = context, .url = url_copy }); }
/// Clear the context cache pub fn clearCache(self: *Self) void { var iter = self.cache.iterator(); while (iter.next()) |entry| { self.allocator.free(entry.key_ptr.*); freeValue(self.allocator, entry.value_ptr.context); } self.cache.clearRetainingCapacity(); self.cache_hits = 0; self.cache_misses = 0; }
/// Get cache statistics pub fn getCacheStats(self: *Self) struct { hits: usize, misses: usize, size: usize } { return .{ .hits = self.cache_hits, .misses = self.cache_misses, .size = self.cache.count(), }; }
/// Create a document_loader callback function for use with Options. /// /// LIMITATION (GAP-010): Zig has no stateful closures, so the returned /// function pointer cannot capture this loader instance. It therefore /// returns the `staticLoader` placeholder, which resolves nothing. Because /// `processContext` now treats a null loader result for a URL-valued /// `@context` (or `@import`) as `error.ContextNotLoaded` / /// `error.ContextImportFailed` rather than silently ignoring it, wiring this /// default loader up does NOT produce silently-wrong output — URL contexts /// will surface a clear error. To actually resolve remote contexts, supply /// your own top-level `fn ([]const u8) ?json.Value` in `Options.document_loader` /// that consults a `RemoteContextLoader` (or any cache/preload table) you own. pub fn createDocumentLoader(self: *Self) *const fn ([]const u8) ?json.Value { _ = self; return &staticLoader; }};
// Static loader placeholder - users should create their own callbacks.//// Always returns null. A null result for a URL-valued @context is no longer// silently ignored by the processor: it surfaces as error.ContextNotLoaded// (or error.ContextImportFailed for @import), so this placeholder can never// cause silently-wrong expansion/compaction output.fn staticLoader(url: []const u8) ?json.Value { _ = url; return null;}
/// Active context for JSON-LD processingpub const Context = struct { allocator: std.mem.Allocator, /// Term definitions terms: std.StringHashMap(TermDefinition), /// Base IRI base: ?[]const u8, /// Vocabulary mapping vocab: ?[]const u8, /// Default language language: ?[]const u8, /// Default direction direction: ?Direction, /// Previous context (for propagation) previous: ?*Context,
pub const Direction = enum { ltr, rtl };
pub fn init(allocator: std.mem.Allocator) Context { return .{ .allocator = allocator, .terms = std.StringHashMap(TermDefinition).init(allocator), .base = null, .vocab = null, .language = null, .direction = null, .previous = null, }; }
pub fn deinit(self: *Context) void { var iter = self.terms.iterator(); while (iter.next()) |entry| { self.allocator.free(entry.key_ptr.*); entry.value_ptr.deinit(self.allocator); } self.terms.deinit(); if (self.base) |b| self.allocator.free(b); if (self.vocab) |v| self.allocator.free(v); if (self.language) |l| self.allocator.free(l); }
/// Clone the context pub fn clone(self: *Context) !*Context { const new_ctx = try self.allocator.create(Context); new_ctx.* = Context.init(self.allocator); new_ctx.base = if (self.base) |b| try self.allocator.dupe(u8, b) else null; new_ctx.vocab = if (self.vocab) |v| try self.allocator.dupe(u8, v) else null; new_ctx.language = if (self.language) |l| try self.allocator.dupe(u8, l) else null; new_ctx.direction = self.direction;
var iter = self.terms.iterator(); while (iter.next()) |entry| { const key = try self.allocator.dupe(u8, entry.key_ptr.*); try new_ctx.terms.put(key, try entry.value_ptr.clone(self.allocator)); }
return new_ctx; }
/// Get term definition pub fn getTerm(self: *const Context, term: []const u8) ?TermDefinition { return self.terms.get(term); }
/// Expand a term to an IRI /// Expand an IRI value. Returns newly allocated memory that the caller must free. pub fn expandIri(self: *const Context, value: []const u8, vocab: bool, document_relative: bool) !?[]const u8 { _ = document_relative; // Keywords expand to themselves (return owned copy) if (Keywords.isKeyword(value)) return try self.allocator.dupe(u8, value);
// Check term definitions (return owned copy) if (self.terms.get(value)) |term_def| { if (term_def.iri) |iri| { return try self.allocator.dupe(u8, iri); } return null; }
// Handle compact IRIs const iri_type = getIriType(value); if (iri_type == .compact) { const colon_pos = std.mem.indexOf(u8, value, ":").?; const prefix = value[0..colon_pos]; const suffix = value[colon_pos + 1 ..];
if (self.terms.get(prefix)) |prefix_def| { if (prefix_def.prefix) { // Concatenate prefix IRI with suffix if (prefix_def.iri) |base_iri| { const result = try self.allocator.alloc(u8, base_iri.len + suffix.len); @memcpy(result[0..base_iri.len], base_iri); @memcpy(result[base_iri.len..], suffix); return result; } } } }
// Use vocabulary mapping if (vocab and self.vocab != null) { if (self.vocab) |v| { const result = try self.allocator.alloc(u8, v.len + value.len); @memcpy(result[0..v.len], v); @memcpy(result[v.len..], value); return result; } }
// Return absolute IRIs as-is (return owned copy) if (iri_type == .absolute) return try self.allocator.dupe(u8, value);
return null; }};
/// Term definition in active contextpub const TermDefinition = struct { /// The IRI this term expands to iri: ?[]const u8, /// Whether this term is a prefix prefix: bool, /// Type coercion type_mapping: ?[]const u8, /// Language tagging language: ?[]const u8, /// Direction direction: ?Context.Direction, /// Container type container: ContainerType, /// Nest term nest: ?[]const u8, /// Index mapping index: ?[]const u8, /// Context for this term local_context: ?json.Value, /// Whether definition is protected protected: bool, /// Reverse property reverse: bool,
pub const ContainerType = enum { none, list, set, index, language, id, graph, type_kw, };
pub fn init() TermDefinition { return .{ .iri = null, .prefix = false, .type_mapping = null, .language = null, .direction = null, .container = .none, .nest = null, .index = null, .local_context = null, .protected = false, .reverse = false, }; }
pub fn deinit(self: *TermDefinition, allocator: std.mem.Allocator) void { if (self.iri) |i| allocator.free(i); if (self.type_mapping) |t| allocator.free(t); if (self.language) |l| allocator.free(l); if (self.nest) |n| allocator.free(n); if (self.index) |i| allocator.free(i); }
pub fn clone(self: *const TermDefinition, allocator: std.mem.Allocator) !TermDefinition { return .{ .iri = if (self.iri) |i| try allocator.dupe(u8, i) else null, .prefix = self.prefix, .type_mapping = if (self.type_mapping) |t| try allocator.dupe(u8, t) else null, .language = if (self.language) |l| try allocator.dupe(u8, l) else null, .direction = self.direction, .container = self.container, .nest = if (self.nest) |n| try allocator.dupe(u8, n) else null, .index = if (self.index) |i| try allocator.dupe(u8, i) else null, .local_context = self.local_context, .protected = self.protected, .reverse = self.reverse, }; }};
/// JSON-LD Processorpub const Processor = struct { allocator: std.mem.Allocator, options: Options, active_context: Context, /// Per-instance blank node counter. Used to mint deterministic blank node /// identifiers (`_:bN`) during flattening. Being per-instance (rather than a /// module-global `var`) makes blank node numbering deterministic per run and /// free of cross-instance data races / collisions. blank_node_counter: u64, /// Set of remote `@context` URLs currently being resolved. Used by /// `processContext` to detect resolution cycles (a loader returning a /// self-referential or mutually-referential context) and fail with /// `error.RecursiveContext` instead of looping forever. resolving_contexts: std.StringHashMap(void),
const Self = @This();
pub fn init(allocator: std.mem.Allocator, options: Options) Self { return .{ .allocator = allocator, .options = options, .active_context = Context.init(allocator), .blank_node_counter = 0, .resolving_contexts = std.StringHashMap(void).init(allocator), }; }
pub fn deinit(self: *Self) void { self.active_context.deinit(); self.resolving_contexts.deinit(); }
// ========================================================================= // Expansion Algorithm (Section 5.1) // =========================================================================
/// Expand a JSON-LD document pub fn expand(self: *Self, document: json.Value) !json.Value { return self.expandInternal(document, &self.active_context, null, 0); }
fn expandInternal( self: *Self, element: json.Value, active_context: *Context, active_property: ?[]const u8, depth: u32, ) !json.Value { _ = active_property;
if (depth > self.options.max_depth) return Error.MaxDepthExceeded;
switch (element) { .null => return .null, .bool => |b| return .{ .bool = b }, .num => |n| return .{ .num = n }, .str => |s| { // String values are expanded based on context if (self.active_context.vocab != null) { // Expand using vocabulary if (try active_context.expandIri(s, true, false)) |expanded| { return .{ .str = expanded }; } } return .{ .str = try self.allocator.dupe(u8, s) }; }, .arr => |arr| { // Expand each element in the array var result: std.ArrayList(json.Value) = .empty; // Contents-freeing errdefer (NASR-002): on the error unwind // (e.g. MaxDepthExceeded) free each already-expanded child // subtree before deiniting the spine, not just the buffer. errdefer { for (result.items) |item| freeValue(self.allocator, item); result.deinit(self.allocator); }
for (arr) |item| { const expanded = try self.expandInternal(item, active_context, null, depth + 1); if (expanded != .null) { try result.append(self.allocator, expanded); } }
return .{ .arr = try result.toOwnedSlice(self.allocator) }; }, .obj => |obj| { // Check for @context and process it var local_context = active_context; var free_local = false;
for (obj) |member| { if (std.mem.eql(u8, member.k, Keywords.context)) { local_context = try self.processContext(active_context, member.v, depth + 1); free_local = true; break; } } defer if (free_local) { local_context.deinit(); self.allocator.destroy(local_context); };
// Expand object properties var result: std.ArrayList(json.Member) = .empty; // Contents-freeing errdefer (NASR-002): on the error unwind free // each already-appended member's key string and expanded value // subtree before deiniting the spine. The bare spine-only // `result.deinit` freed only the buffer, leaking the prefix. errdefer { for (result.items) |m| { self.allocator.free(m.k); freeValue(self.allocator, m.v); } result.deinit(self.allocator); }
for (obj) |member| { // Skip @context as it's already processed if (std.mem.eql(u8, member.k, Keywords.context)) continue;
// Expand property name const expanded_key = try self.expandKey(local_context, member.k); // Free the in-flight key if value expansion below errors // before it is appended (and thus before ownership moves to // `result`). Cleared once ownership transfers. var key_owned = expanded_key != null; errdefer if (key_owned) self.allocator.free(expanded_key.?);
// Expand value const expanded_value = try self.expandInternal(member.v, local_context, member.k, depth + 1); // Free the in-flight value if the append below fails before // ownership moves into `result`. var value_owned = true; errdefer if (value_owned) freeValue(self.allocator, expanded_value);
if (expanded_key != null and expanded_value != .null) { try result.append(self.allocator, .{ .k = expanded_key.?, .v = expanded_value, }); key_owned = false; value_owned = false; } else { // Not appended: free whatever was allocated so it does // not leak on the success path. if (expanded_key) |k| { self.allocator.free(k); key_owned = false; } freeValue(self.allocator, expanded_value); value_owned = false; } }
return .{ .obj = try result.toOwnedSlice(self.allocator) }; }, } }
fn expandKey(self: *Self, context: *Context, key: []const u8) !?[]const u8 { // Keywords remain unchanged if (Keywords.isKeyword(key)) { return try self.allocator.dupe(u8, key); }
// Try to expand using term definitions if (context.getTerm(key)) |term_def| { if (term_def.iri) |iri| { return try self.allocator.dupe(u8, iri); } }
// Try to expand using IRI expansion if (try context.expandIri(key, true, false)) |expanded| { return expanded; }
// Return as-is if can't expand return try self.allocator.dupe(u8, key); }
// ========================================================================= // Context Processing (Section 5.2) // =========================================================================
fn processContext(self: *Self, active_context: *Context, local_context: json.Value, depth: u32) (Error || std.mem.Allocator.Error)!*Context { if (depth > self.options.max_depth) return Error.MaxDepthExceeded;
const result = try active_context.clone(); // NEW-004: `result` is a disposable clone of `active_context` that this // function owns until it is either returned or superseded. The `.arr` // and `.str` branches reassign the live context as they iterate/recurse // and free the prior one in place; `errdefer_target` tracks whichever // context is currently live so this error cleanup frees it exactly once // and never double-frees a context already freed in-loop. On the success // path this errdefer never fires (it is error-only) and the final live // context is handed to the caller. var errdefer_target = result; errdefer { errdefer_target.deinit(); self.allocator.destroy(errdefer_target); }
switch (local_context) { .null => { // Reset to empty context result.terms.clearAndFree(); result.vocab = null; result.base = null; result.language = null; }, .str => |s| { // Context is a URL - load it via the document loader. // // Guard against resolution cycles: a loader that returns a // (directly or transitively) self-referential context would // otherwise recurse forever. Track URLs currently being // resolved and fail with RecursiveContext on re-entry. if (self.resolving_contexts.contains(s)) { return Error.RecursiveContext; }
const loader = self.options.document_loader orelse return Error.ContextNotLoaded; const loaded = loader(s) orelse return Error.ContextNotLoaded;
const url_key = try self.allocator.dupe(u8, s); var key_owned = true; defer if (key_owned) self.allocator.free(url_key); try self.resolving_contexts.put(url_key, {}); key_owned = false; defer { if (self.resolving_contexts.fetchRemove(s)) |kv| { self.allocator.free(kv.key); } }
// NEW-004: the recursive call CLONES `result` (it does not // consume it), so `result` would be orphaned/leaked the same // way the `.arr` seed was. The recursive call needs `result` // live for its internal clone, so we cannot free `result` // first. Capture the recursive result, then free the now-dead // seed `result` and return. // // On the error path the recursive call propagates before // `processed` is bound; the function-level errdefer (which // still targets `result`) frees the seed exactly once -- no // double-free, because `result` is freed below ONLY on the // success path where the errdefer never fires. const processed = try self.processContext(result, loaded, depth + 1); result.deinit(); self.allocator.destroy(result); return processed; }, .obj => |obj| { // JSON-LD 1.1 @import (Section 4.1.2): resolve the referenced // context and merge it BEFORE processing the rest of this // context object. Must be handled first so local definitions // override imported ones. We do not silently drop @import. for (obj) |member| { if (std.mem.eql(u8, member.k, Keywords.import_kw)) { // A non-string @import value is invalid and must not be // silently dropped (NQUAL-007). @import must reference a // context by IRI string; anything else is unresolvable. if (member.v != .str) return Error.ContextImportFailed; const import_url = member.v.str;
// Guard against @import cycles using the same set used // for URL-valued contexts. if (self.resolving_contexts.contains(import_url)) { return Error.RecursiveContext; }
const loader = self.options.document_loader orelse return Error.ContextImportFailed; const imported = loader(import_url) orelse return Error.ContextImportFailed;
const url_key = try self.allocator.dupe(u8, import_url); var key_owned = true; defer if (key_owned) self.allocator.free(url_key); try self.resolving_contexts.put(url_key, {}); key_owned = false; defer { if (self.resolving_contexts.fetchRemove(import_url)) |kv| { self.allocator.free(kv.key); } }
// Merge the imported context into `result` in place by // processing it, then swapping the processed context's // contents for `result`'s. const merged = try self.processContext(result, imported, depth + 1); result.deinit(); result.* = merged.*; self.allocator.destroy(merged); } }
// Process context definition for (obj) |member| { if (std.mem.eql(u8, member.k, Keywords.version)) { // Handle @version continue; } else if (std.mem.eql(u8, member.k, Keywords.import_kw)) { // Already handled above. continue; } else if (std.mem.eql(u8, member.k, Keywords.base)) { // Handle @base if (member.v == .str) { if (result.base) |b| self.allocator.free(b); result.base = try self.allocator.dupe(u8, member.v.str); } } else if (std.mem.eql(u8, member.k, Keywords.vocab)) { // Handle @vocab if (member.v == .str) { if (result.vocab) |v| self.allocator.free(v); result.vocab = try self.allocator.dupe(u8, member.v.str); } } else if (std.mem.eql(u8, member.k, Keywords.language)) { // Handle @language if (member.v == .str) { if (result.language) |l| self.allocator.free(l); result.language = try self.allocator.dupe(u8, member.v.str); } } else if (std.mem.eql(u8, member.k, Keywords.direction)) { // Handle @direction if (member.v == .str) { result.direction = if (std.mem.eql(u8, member.v.str, "ltr")) .ltr else if (std.mem.eql(u8, member.v.str, "rtl")) .rtl else null; } } else if (!Keywords.isKeyword(member.k)) { // Create term definition try self.createTermDefinition(result, member.k, member.v); } } }, .arr => |arr| { // Process array of contexts in order. // // NEW-004: each recursive `processContext` CLONES `current` // (it does not consume it), so after every iteration the prior // `current` is orphaned and must be freed -- including the seed // clone `result` on the first iteration. The old `if (i > 0)` // guard skipped freeing the seed, leaking one Context per call. // // To avoid double-freeing with the function-level errdefer we // advance `errdefer_target` to the new live context BEFORE // freeing the old one: if `processContext` below errors on a // later iteration, the errdefer frees only the still-live // `current` (already freed seeds are no longer referenced). var current = result; for (arr) |ctx| { const processed = try self.processContext(current, ctx, depth + 1); // `processed` is now the live context; make the errdefer // target it, then free the superseded `current`. errdefer_target = processed; current.deinit(); self.allocator.destroy(current); current = processed; } return current; }, else => {}, }
return result; }
fn createTermDefinition(self: *Self, context: *Context, term: []const u8, definition: json.Value) !void { var term_def = TermDefinition.init(); // Partial-build leak guard (NASR/OOM): the fields below (iri / // type_mapping / language) are duped incrementally. If any later dupe -- // or the `key` dupe / `terms.put` at the end -- fails, free the // already-duped fields. `TermDefinition.deinit` frees exactly those owned // slices. errdefer does NOT fire on the early `.null`/`else` returns // (those return normally), and on the success path ownership of // `term_def` moves into the map before this scope exits, so the cleanup // only runs on the OOM/error unwind. errdefer term_def.deinit(self.allocator);
switch (definition) { .null => { // Null removes the term if (context.terms.fetchRemove(term)) |entry| { self.allocator.free(entry.key); var val = entry.value; val.deinit(self.allocator); } return; }, .str => |s| { // Simple term definition - just an IRI mapping term_def.iri = try self.allocator.dupe(u8, s); }, .obj => |obj| { // Full term definition for (obj) |member| { if (std.mem.eql(u8, member.k, Keywords.id)) { if (member.v == .str) { term_def.iri = try self.allocator.dupe(u8, member.v.str); } } else if (std.mem.eql(u8, member.k, Keywords.type_kw)) { if (member.v == .str) { term_def.type_mapping = try self.allocator.dupe(u8, member.v.str); } } else if (std.mem.eql(u8, member.k, Keywords.language)) { if (member.v == .str) { term_def.language = try self.allocator.dupe(u8, member.v.str); } } else if (std.mem.eql(u8, member.k, Keywords.container)) { if (member.v == .str) { term_def.container = self.parseContainerType(member.v.str); } } else if (std.mem.eql(u8, member.k, Keywords.reverse)) { term_def.reverse = member.v == .bool and member.v.bool; } else if (std.mem.eql(u8, member.k, Keywords.prefix)) { term_def.prefix = member.v == .bool and member.v.bool; } else if (std.mem.eql(u8, member.k, Keywords.protected)) { term_def.protected = member.v == .bool and member.v.bool; } }
// If no @id, use term as IRI if (term_def.iri == null) { term_def.iri = try self.allocator.dupe(u8, term); } }, else => return, }
const key = try self.allocator.dupe(u8, term); // If the map insertion fails, free the just-duped key (the `term_def` // errdefer above frees its fields). On success, ownership of both `key` // and `term_def` moves into the map. errdefer self.allocator.free(key); try context.terms.put(key, term_def); }
fn parseContainerType(self: *Self, value: []const u8) TermDefinition.ContainerType { _ = self; if (std.mem.eql(u8, value, Keywords.list)) return .list; if (std.mem.eql(u8, value, Keywords.set)) return .set; if (std.mem.eql(u8, value, Keywords.index)) return .index; if (std.mem.eql(u8, value, Keywords.language)) return .language; if (std.mem.eql(u8, value, Keywords.id)) return .id; if (std.mem.eql(u8, value, Keywords.graph)) return .graph; if (std.mem.eql(u8, value, Keywords.type_kw)) return .type_kw; return .none; }
// ========================================================================= // Compaction Algorithm (Section 5.3) // =========================================================================
/// Compact an expanded JSON-LD document pub fn compact(self: *Self, document: json.Value, context: json.Value) !json.Value { // First, expand the document const expanded = try self.expand(document); defer freeValue(self.allocator, expanded);
// Process the compaction context const compact_context = try self.processContext(&self.active_context, context, 0); defer { compact_context.deinit(); self.allocator.destroy(compact_context); }
// Compact the expanded document return self.compactInternal(expanded, compact_context, 0); }
fn compactInternal(self: *Self, element: json.Value, context: *Context, depth: u32) (Error || std.mem.Allocator.Error)!json.Value { if (depth > self.options.max_depth) return Error.MaxDepthExceeded;
switch (element) { .null, .bool, .num => return element, .str => |s| { // Try to compact IRI return .{ .str = try self.compactIri(context, s) }; }, .arr => |arr| { var result: std.ArrayList(json.Value) = .empty; // Contents-freeing errdefer (NASR-002): free already-compacted // child subtrees on the error unwind, not just the buffer. errdefer { for (result.items) |item| freeValue(self.allocator, item); result.deinit(self.allocator); }
for (arr) |item| { const compacted = try self.compactInternal(item, context, depth + 1); // Free the in-flight subtree if the append fails before // ownership moves into `result`. var owned = true; errdefer if (owned) freeValue(self.allocator, compacted); try result.append(self.allocator, compacted); owned = false; }
// Compact arrays with single element if enabled if (self.options.compact_arrays and result.items.len == 1) { const single = result.items[0]; result.deinit(self.allocator); return single; }
return .{ .arr = try result.toOwnedSlice(self.allocator) }; }, .obj => |obj| { var result: std.ArrayList(json.Member) = .empty; // Contents-freeing errdefer (NASR-002): free each already-built // member's key string and compacted value subtree on the error // unwind before deiniting the spine. errdefer { for (result.items) |m| { self.allocator.free(m.k); freeValue(self.allocator, m.v); } result.deinit(self.allocator); }
for (obj) |member| { const compacted_key = try self.compactIri(context, member.k); // Free the in-flight key if value compaction errors before // the member is appended (ownership moves on append). var key_owned = true; errdefer if (key_owned) self.allocator.free(compacted_key);
const compacted_value = try self.compactInternal(member.v, context, depth + 1); // Free the in-flight value if the append fails before // ownership moves into `result`. var value_owned = true; errdefer if (value_owned) freeValue(self.allocator, compacted_value); try result.append(self.allocator, .{ .k = compacted_key, .v = compacted_value, }); key_owned = false; value_owned = false; }
return .{ .obj = try result.toOwnedSlice(self.allocator) }; }, } }
fn compactIri(self: *Self, context: *Context, iri: []const u8) ![]const u8 { // Check if IRI matches a term definition var iter = context.terms.iterator(); while (iter.next()) |entry| { if (entry.value_ptr.iri) |term_iri| { if (std.mem.eql(u8, term_iri, iri)) { return try self.allocator.dupe(u8, entry.key_ptr.*); } } }
// Try to create compact IRI with prefix if (self.options.compact_iris) { iter = context.terms.iterator(); while (iter.next()) |entry| { if (entry.value_ptr.prefix) { if (entry.value_ptr.iri) |prefix_iri| { if (std.mem.startsWith(u8, iri, prefix_iri)) { const suffix = iri[prefix_iri.len..]; const result = try self.allocator.alloc(u8, entry.key_ptr.len + 1 + suffix.len); @memcpy(result[0..entry.key_ptr.len], entry.key_ptr.*); result[entry.key_ptr.len] = ':'; @memcpy(result[entry.key_ptr.len + 1 ..], suffix); return result; } } } } }
// Return IRI as-is return try self.allocator.dupe(u8, iri); }
// ========================================================================= // Flattening Algorithm (Section 5.4) // =========================================================================
/// Flatten a JSON-LD document pub fn flatten(self: *Self, document: json.Value, context: ?json.Value) !json.Value { // First, expand the document const expanded = try self.expand(document); defer freeValue(self.allocator, expanded);
// Generate node map var node_map = std.StringHashMap(json.Value).init(self.allocator); defer { var iter = node_map.iterator(); while (iter.next()) |entry| { self.allocator.free(entry.key_ptr.*); freeValue(self.allocator, entry.value_ptr.*); } node_map.deinit(); }
try self.generateNodeMap(expanded, &node_map, null, 0);
// Create flattened output. // // PERF-010: transfer ownership of each node out of the node map instead // of deep-cloning it a second time. `generateNodeMap` already produced // an owned deep copy in the map; we move that value into the graph and // replace the map slot with `.null` so the map's cleanup defer does not // double-free it. The key remains owned by the map (freed by the defer). var graph: std.ArrayList(json.Value) = .empty; errdefer graph.deinit(self.allocator);
var iter = node_map.iterator(); while (iter.next()) |entry| { if (entry.value_ptr.* == .obj) { // Move the node out of the map (ownership transfer, no clone). const moved = entry.value_ptr.*; entry.value_ptr.* = .null; try graph.append(self.allocator, moved); } }
// Create result with @graph var result_members: std.ArrayList(json.Member) = .empty; errdefer result_members.deinit(self.allocator);
try result_members.append(self.allocator, .{ .k = try self.allocator.dupe(u8, Keywords.graph), .v = .{ .arr = try graph.toOwnedSlice(self.allocator) }, });
const result = json.Value{ .obj = try result_members.toOwnedSlice(self.allocator) };
// Compact if context provided if (context) |ctx| { const compacted = try self.compact(result, ctx); freeValue(self.allocator, result); return compacted; }
return result; }
fn generateNodeMap( self: *Self, element: json.Value, node_map: *std.StringHashMap(json.Value), active_graph: ?[]const u8, depth: u32, ) (Error || std.mem.Allocator.Error)!void { if (depth > self.options.max_depth) return Error.MaxDepthExceeded;
switch (element) { .arr => |arr| { for (arr) |item| { try self.generateNodeMap(item, node_map, active_graph, depth + 1); } }, .obj => |obj| { // Get or generate @id var id: ?[]const u8 = null; for (obj) |member| { if (std.mem.eql(u8, member.k, Keywords.id)) { if (member.v == .str) { id = member.v.str; break; } } }
if (id == null) { // Generate blank node ID id = try self.generateBlankNodeId(); }
// Clone the object into node map (depth-bounded so an untrusted // deeply nested node cannot overflow the stack here either). const key = try self.allocator.dupe(u8, id.?); const value = try cloneValueDepth(self.allocator, element, depth, self.options.max_depth); try node_map.put(key, value);
// Process nested nodes for (obj) |member| { if (member.v == .obj or member.v == .arr) { try self.generateNodeMap(member.v, node_map, active_graph, depth + 1); } } }, else => {}, } }
fn generateBlankNodeId(self: *Self) ![]const u8 { // Per-instance counter (ASR-023 / GAP-009): blank node ids are // deterministic per processing run and free of cross-instance data // races, rather than sharing one module-global counter across every // processor instance. const id = self.blank_node_counter; self.blank_node_counter += 1;
var buf: [32]u8 = undefined; const digits = std.fmt.bufPrint(&buf, "{d}", .{id}) catch unreachable; const result = try self.allocator.alloc(u8, 3 + digits.len); result[0] = '_'; result[1] = ':'; result[2] = 'b'; @memcpy(result[3..], digits); return result; }
// ========================================================================= // Framing Algorithm (Section 5.5) // =========================================================================
/// Frame a JSON-LD document pub fn frame(self: *Self, document: json.Value, frame_val: json.Value) !json.Value { // First, expand the document const expanded = try self.expand(document); defer freeValue(self.allocator, expanded);
// Expand the frame const expanded_frame = try self.expand(frame_val); defer freeValue(self.allocator, expanded_frame);
// Frame the document return self.frameInternal(expanded, expanded_frame, 0); }
fn frameInternal(self: *Self, input: json.Value, frame_val: json.Value, depth: u32) (Error || std.mem.Allocator.Error)!json.Value { if (depth > self.options.max_depth) return Error.MaxDepthExceeded;
// Match input against frame pattern if (frame_val != .obj) { return try cloneValueDepth(self.allocator, input, depth, self.options.max_depth); }
const frame_obj = frame_val.obj;
if (input == .arr) { var result: std.ArrayList(json.Value) = .empty; // Contents-freeing errdefer (NASR-002): free already-framed child // subtrees on the error unwind, not just the buffer. errdefer { for (result.items) |item| freeValue(self.allocator, item); result.deinit(self.allocator); }
for (input.arr) |item| { if (try self.matchesFrame(item, frame_obj)) { const framed = try self.frameInternal(item, frame_val, depth + 1); try result.append(self.allocator, framed); } }
return .{ .arr = try result.toOwnedSlice(self.allocator) }; }
if (input != .obj) { return try cloneValueDepth(self.allocator, input, depth, self.options.max_depth); }
// Match object against frame if (!try self.matchesFrame(input, frame_obj)) { return .null; }
// Build framed result var result: std.ArrayList(json.Member) = .empty; // Contents-freeing errdefer (NASR-002): free each already-built // member's key string and framed value subtree on the error unwind // before deiniting the spine. errdefer { for (result.items) |m| { self.allocator.free(m.k); freeValue(self.allocator, m.v); } result.deinit(self.allocator); }
// Include all properties from frame for (frame_obj) |frame_member| { // Find matching property in input for (input.obj) |input_member| { if (std.mem.eql(u8, frame_member.k, input_member.k)) { const framed_value = try self.frameInternal(input_member.v, frame_member.v, depth + 1); // Free the in-flight framed value if the key dupe or append // below fails before ownership moves into `result`. errdefer freeValue(self.allocator, framed_value); const framed_key = try self.allocator.dupe(u8, frame_member.k); errdefer self.allocator.free(framed_key); try result.append(self.allocator, .{ .k = framed_key, .v = framed_value, }); break; } } }
return .{ .obj = try result.toOwnedSlice(self.allocator) }; }
fn matchesFrame(self: *Self, value: json.Value, frame_obj: []const json.Member) !bool { _ = self; if (value != .obj) return false;
// Check @type constraint for (frame_obj) |frame_member| { if (std.mem.eql(u8, frame_member.k, Keywords.type_kw)) { var found = false; for (value.obj) |value_member| { if (std.mem.eql(u8, value_member.k, Keywords.type_kw)) { if (valuesMatch(frame_member.v, value_member.v)) { found = true; break; } } } if (!found and frame_member.v != .obj) { return false; } } }
return true; }
fn valuesMatch(a: json.Value, b: json.Value) bool { if (@backingInt(a) != @backingInt(b)) return false;
switch (a) { .null => return true, .bool => |av| return av == b.bool, .str => |as| return std.mem.eql(u8, as, b.str), .num => |an| return std.mem.eql(u8, an.lexeme, b.num.lexeme), .arr => |aa| { if (aa.len != b.arr.len) return false; for (aa, b.arr) |ai, bi| { if (!valuesMatch(ai, bi)) return false; } return true; }, .obj => return false, // Objects don't match directly } }
// ========================================================================= // N-Quads Serialization (Section 9) // =========================================================================
/// Convert JSON-LD to N-Quads format /// Note: The returned string is owned by the caller. /// Warning: expand() may return references to input document strings, /// so the input document must remain valid until this function returns. /// The caller is responsible for managing the input document's memory. pub fn toNQuads(self: *Self, document: json.Value) ![]const u8 { // Expand the document // Note: expanded result may share memory with input document const expanded = try self.expand(document); // Don't free expanded as it may reference input strings
var result: std.ArrayList(u8) = .empty; errdefer result.deinit(self.allocator);
try self.serializeNQuads(&result, expanded, null);
return result.toOwnedSlice(self.allocator); }
fn serializeNQuads(self: *Self, output: *std.ArrayList(u8), element: json.Value, subject: ?[]const u8) !void { switch (element) { .arr => |arr| { for (arr) |item| { try self.serializeNQuads(output, item, subject); } }, .obj => |obj| { // Get subject var subj = subject; for (obj) |member| { if (std.mem.eql(u8, member.k, Keywords.id)) { if (member.v == .str) { subj = member.v.str; break; } } }
if (subj == null) { subj = "_:b0"; }
// Serialize each predicate-object pair for (obj) |member| { if (std.mem.eql(u8, member.k, Keywords.id)) continue; if (std.mem.eql(u8, member.k, Keywords.context)) continue;
const predicate = member.k;
try self.serializeQuad(output, subj.?, predicate, member.v); } }, else => {}, } }
fn serializeQuad(self: *Self, output: *std.ArrayList(u8), subject: []const u8, predicate: []const u8, object: json.Value) !void { // Subject if (std.mem.startsWith(u8, subject, "_:")) { try output.appendSlice(self.allocator, subject); } else { try output.append(self.allocator, '<'); try output.appendSlice(self.allocator, subject); try output.append(self.allocator, '>'); } try output.append(self.allocator, ' ');
// Predicate try output.append(self.allocator, '<'); try output.appendSlice(self.allocator, predicate); try output.append(self.allocator, '>'); try output.append(self.allocator, ' ');
// Object switch (object) { .str => |s| { try output.append(self.allocator, '"'); try self.escapeNQuadsString(output, s); try output.append(self.allocator, '"'); }, .bool => |b| { try output.appendSlice(self.allocator, if (b) "\"true\"" else "\"false\""); try output.appendSlice(self.allocator, "^^<http://www.w3.org/2001/XMLSchema#boolean>"); }, .num => |n| { try output.append(self.allocator, '"'); try output.appendSlice(self.allocator, n.lexeme); try output.append(self.allocator, '"'); if (std.mem.indexOf(u8, n.lexeme, ".") != null) { try output.appendSlice(self.allocator, "^^<http://www.w3.org/2001/XMLSchema#double>"); } else { try output.appendSlice(self.allocator, "^^<http://www.w3.org/2001/XMLSchema#integer>"); } }, .obj => |obj| { // Check for @value for (obj) |member| { if (std.mem.eql(u8, member.k, Keywords.value)) { return self.serializeQuad(output, subject, predicate, member.v); } if (std.mem.eql(u8, member.k, Keywords.id)) { if (member.v == .str) { if (std.mem.startsWith(u8, member.v.str, "_:")) { try output.appendSlice(self.allocator, member.v.str); } else { try output.append(self.allocator, '<'); try output.appendSlice(self.allocator, member.v.str); try output.append(self.allocator, '>'); } try output.appendSlice(self.allocator, " .\n"); return; } } } return; }, .arr => |arr| { for (arr) |item| { try self.serializeQuad(output, subject, predicate, item); } return; }, .null => return, }
try output.appendSlice(self.allocator, " .\n"); }
fn escapeNQuadsString(self: *Self, output: *std.ArrayList(u8), s: []const u8) !void { for (s) |c| { switch (c) { '\n' => try output.appendSlice(self.allocator, "\\n"), '\r' => try output.appendSlice(self.allocator, "\\r"), '"' => try output.appendSlice(self.allocator, "\\\""), '\\' => try output.appendSlice(self.allocator, "\\\\"), else => try output.append(self.allocator, c), } } }};
// =============================================================================// Helper Functions// =============================================================================
fn freeValue(allocator: std.mem.Allocator, value: json.Value) void { switch (value) { .null, .bool, .num => {}, .str => |s| allocator.free(s), .arr => |arr| { for (arr) |item| { freeValue(allocator, item); } allocator.free(arr); }, .obj => |obj| { for (obj) |m| { allocator.free(m.k); freeValue(allocator, m.v); } allocator.free(obj); }, }}
fn cloneValue(allocator: std.mem.Allocator, value: json.Value) !json.Value { switch (value) { .null => return .null, .bool => |b| return .{ .bool = b }, .num => |n| return .{ .num = n }, .str => |s| return .{ .str = try allocator.dupe(u8, s) }, .arr => |arr| { var result = try allocator.alloc(json.Value, arr.len); for (arr, 0..) |item, i| { result[i] = try cloneValue(allocator, item); } return .{ .arr = result }; }, .obj => |obj| { var result = try allocator.alloc(json.Member, obj.len); for (obj, 0..) |m, i| { result[i] = .{ .k = try allocator.dupe(u8, m.k), .v = try cloneValue(allocator, m.v), }; } return .{ .obj = result }; }, }}
/// Depth-bounded deep clone. Mirrors `cloneValue` but returns/// `Error.MaxDepthExceeded` once `depth` exceeds `max_depth`, so cloning an/// untrusted, deeply nested `Value` tree cannot overflow the stack. Partial/// allocations on the error path are released so no memory leaks.fn cloneValueDepth( allocator: std.mem.Allocator, value: json.Value, depth: u32, max_depth: u32,) (Error || std.mem.Allocator.Error)!json.Value { if (depth > max_depth) return Error.MaxDepthExceeded;
switch (value) { .null => return .null, .bool => |b| return .{ .bool = b }, .num => |n| return .{ .num = n }, .str => |s| return .{ .str = try allocator.dupe(u8, s) }, .arr => |arr| { var result = try allocator.alloc(json.Value, arr.len); var filled: usize = 0; errdefer { for (result[0..filled]) |item| freeValue(allocator, item); allocator.free(result); } for (arr, 0..) |item, i| { result[i] = try cloneValueDepth(allocator, item, depth + 1, max_depth); filled = i + 1; } return .{ .arr = result }; }, .obj => |obj| { var result = try allocator.alloc(json.Member, obj.len); var filled: usize = 0; errdefer { for (result[0..filled]) |m| { allocator.free(m.k); freeValue(allocator, m.v); } allocator.free(result); } for (obj, 0..) |m, i| { const k = try allocator.dupe(u8, m.k); errdefer allocator.free(k); const v = try cloneValueDepth(allocator, m.v, depth + 1, max_depth); result[i] = .{ .k = k, .v = v }; filled = i + 1; } return .{ .obj = result }; }, }}
// =============================================================================// Convenience Functions// =============================================================================
/// Expand a JSON-LD documentpub fn expand(allocator: std.mem.Allocator, document: json.Value, options: Options) !json.Value { var processor = Processor.init(allocator, options); defer processor.deinit(); return processor.expand(document);}
/// Compact a JSON-LD documentpub fn compact(allocator: std.mem.Allocator, document: json.Value, context: json.Value, options: Options) !json.Value { var processor = Processor.init(allocator, options); defer processor.deinit(); return processor.compact(document, context);}
/// Flatten a JSON-LD documentpub fn flatten(allocator: std.mem.Allocator, document: json.Value, context: ?json.Value, options: Options) !json.Value { var processor = Processor.init(allocator, options); defer processor.deinit(); return processor.flatten(document, context);}
/// Frame a JSON-LD documentpub fn frame(allocator: std.mem.Allocator, document: json.Value, frame_val: json.Value, options: Options) !json.Value { var processor = Processor.init(allocator, options); defer processor.deinit(); return processor.frame(document, frame_val);}
/// Convert JSON-LD to N-Quadspub fn toNQuads(allocator: std.mem.Allocator, document: json.Value, options: Options) ![]const u8 { var processor = Processor.init(allocator, options); defer processor.deinit(); return processor.toNQuads(document);}
// =============================================================================// Tests// =============================================================================
test "Keywords.isKeyword" { try std.testing.expect(Keywords.isKeyword("@context")); try std.testing.expect(Keywords.isKeyword("@id")); try std.testing.expect(Keywords.isKeyword("@type")); try std.testing.expect(!Keywords.isKeyword("name")); try std.testing.expect(!Keywords.isKeyword(""));}
test "getIriType" { try std.testing.expectEqual(IriType.absolute, getIriType("http://example.org/")); try std.testing.expectEqual(IriType.compact, getIriType("ex:name")); try std.testing.expectEqual(IriType.blank, getIriType("_:b0")); try std.testing.expectEqual(IriType.keyword, getIriType("@type")); try std.testing.expectEqual(IriType.relative, getIriType("name"));}
test "Context basic operations" { const allocator = std.testing.allocator; var ctx = Context.init(allocator); defer ctx.deinit();
// Add a term var term_def = TermDefinition.init(); term_def.iri = try allocator.dupe(u8, "http://example.org/name"); const key = try allocator.dupe(u8, "name"); try ctx.terms.put(key, term_def);
const found = ctx.getTerm("name"); try std.testing.expect(found != null); try std.testing.expectEqualStrings("http://example.org/name", found.?.iri.?);}
test "Processor expand simple" { const allocator = std.testing.allocator; var processor = Processor.init(allocator, .{}); defer processor.deinit();
const result = try processor.expand(.{ .str = "hello" }); defer freeValue(allocator, result);
try std.testing.expect(result == .str); try std.testing.expectEqualStrings("hello", result.str);}
test "Processor expand with context" { const allocator = std.testing.allocator; var processor = Processor.init(allocator, .{}); defer processor.deinit();
// Create a document with context var ctx_members = [_]json.Member{ .{ .k = "name", .v = .{ .str = "http://schema.org/name" } }, }; var doc_members = [_]json.Member{ .{ .k = "@context", .v = .{ .obj = &ctx_members } }, .{ .k = "name", .v = .{ .str = "Alice" } }, };
const result = try processor.expand(.{ .obj = &doc_members }); defer freeValue(allocator, result);
try std.testing.expect(result == .obj);}
test "toNQuads basic" { // Use arena allocator to avoid tracking individual allocations // since expand() has complex shared memory semantics var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator();
// Use compile-time string literals which don't need freeing var members = [_]json.Member{ .{ .k = "@id", .v = .{ .str = "http://example.org/person/1" } }, .{ .k = "http://schema.org/name", .v = .{ .str = "Alice" } }, };
const nquads = try toNQuads(allocator, .{ .obj = &members }, .{});
try std.testing.expect(nquads.len > 0); try std.testing.expect(std.mem.indexOf(u8, nquads, "Alice") != null);}
test "RemoteContextLoader init/deinit" { // Use arena allocator since contexts may reference string literals var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator();
var loader = RemoteContextLoader.init(allocator); defer loader.deinit();
// Initial stats should be zero const stats = loader.getCacheStats(); try std.testing.expectEqual(@as(usize, 0), stats.hits); try std.testing.expectEqual(@as(usize, 0), stats.misses); try std.testing.expectEqual(@as(usize, 0), stats.size);}
test "RemoteContextLoader cache operations" { // Use arena allocator since contexts may reference string literals var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator();
var loader = RemoteContextLoader.init(allocator); // Don't defer deinit - arena handles cleanup
// Preload a simple context (just null value to avoid string freeing issues) try loader.preloadContext("https://example.org/context.jsonld", .null);
// Should be in cache const stats = loader.getCacheStats(); try std.testing.expectEqual(@as(usize, 1), stats.size);
// Load should return the cached context const loaded = loader.loadContext("https://example.org/context.jsonld"); try std.testing.expect(loaded != null); try std.testing.expect(loaded.? == .null);
// Should count as hit const stats2 = loader.getCacheStats(); try std.testing.expectEqual(@as(usize, 1), stats2.hits);}
test "RemoteContextLoader loadContext miss" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator();
var loader = RemoteContextLoader.init(allocator);
// Without a fetch function, load should fail const loaded = loader.loadContext("https://example.org/unknown.jsonld"); try std.testing.expect(loaded == null);
// Should count as miss const stats = loader.getCacheStats(); try std.testing.expectEqual(@as(usize, 1), stats.misses);}
test "RemoteContextLoader clearCache" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator();
var loader = RemoteContextLoader.init(allocator);
// Preload a context (use null to avoid string issues) try loader.preloadContext("https://example.org/test.jsonld", .null);
// Verify it's there try std.testing.expectEqual(@as(usize, 1), loader.getCacheStats().size);
// Clear the cache loader.clearCache();
// Should be empty try std.testing.expectEqual(@as(usize, 0), loader.getCacheStats().size); try std.testing.expectEqual(@as(usize, 0), loader.getCacheStats().hits);}
// NEW-005: fetch function for the cache-eviction UAF test. The loader frees the// returned content with its own allocator, so the content must be allocated by// the same allocator. A bare `fn` pointer carries no allocator, so the test// publishes one here before driving the loader.var new005_fetch_allocator: ?std.mem.Allocator = null;fn new005FetchFn(url: []const u8) RemoteContextLoader.FetchError![]const u8 { _ = url; const a = new005_fetch_allocator orelse return error.NetworkError; // Minimal valid JSON object; parsed and cached by loadContext. return a.dupe(u8, "{}") catch return error.NetworkError;}
test "NEW-005: cache eviction does not use freed key (no UAF)" { // Leak/UAF-detecting allocator (NOT an arena): a use-after-free read of the // evicted key and any leak of the evicted entry are caught here. const allocator = std.testing.allocator;
var loader = RemoteContextLoader.init(allocator); defer loader.deinit();
// Shrink the cache so a single extra load forces an eviction. loader.max_cache_size = 2; new005_fetch_allocator = allocator; defer new005_fetch_allocator = null; loader.setFetchFunction(new005FetchFn);
// Fill the cache to capacity with real heap-owned keys/values (preload dupes // the URL key with `allocator`, so eviction must free it with `allocator`). var ctx_a = [_]json.Member{.{ .k = "a", .v = .{ .str = "http://schema.org/a" } }}; var ctx_b = [_]json.Member{.{ .k = "b", .v = .{ .str = "http://schema.org/b" } }}; try loader.preloadContext("https://example.org/a.jsonld", try cloneValue(allocator, .{ .obj = &ctx_a })); try loader.preloadContext("https://example.org/b.jsonld", try cloneValue(allocator, .{ .obj = &ctx_b })); try std.testing.expectEqual(@as(usize, 2), loader.getCacheStats().size);
// A fresh load (miss) triggers the eviction branch. Before the fix this // hashed/compared the just-freed key pointer -> UAF; after the fix it // fetchRemoves while the key is still live. const loaded = loader.loadContext("https://example.org/c.jsonld"); try std.testing.expect(loaded != null);
// Cache stayed at capacity (one evicted, one inserted). try std.testing.expectEqual(@as(usize, 2), loader.getCacheStats().size);}
// =============================================================================// JSON-LD Edge Case Tests// =============================================================================
test "getIriType edge cases" { // Empty string try std.testing.expectEqual(IriType.relative, getIriType(""));
// Single colon - treated as relative (empty prefix) try std.testing.expectEqual(IriType.relative, getIriType(":"));
// Blank node try std.testing.expectEqual(IriType.blank, getIriType("_:b0")); try std.testing.expectEqual(IriType.blank, getIriType("_:"));
// HTTP vs HTTPS try std.testing.expectEqual(IriType.absolute, getIriType("http://example.org")); try std.testing.expectEqual(IriType.absolute, getIriType("https://example.org"));
// URN - treated as compact IRI (urn is the prefix) try std.testing.expectEqual(IriType.compact, getIriType("urn:isbn:123"));
// Compact IRI try std.testing.expectEqual(IriType.compact, getIriType("schema:name")); try std.testing.expectEqual(IriType.compact, getIriType("foaf:Person"));}
test "Keywords.isKeyword edge cases" { // All standard keywords try std.testing.expect(Keywords.isKeyword("@context")); try std.testing.expect(Keywords.isKeyword("@id")); try std.testing.expect(Keywords.isKeyword("@type")); try std.testing.expect(Keywords.isKeyword("@value")); try std.testing.expect(Keywords.isKeyword("@graph")); try std.testing.expect(Keywords.isKeyword("@list")); try std.testing.expect(Keywords.isKeyword("@set")); try std.testing.expect(Keywords.isKeyword("@language"));
// isKeyword returns true for anything starting with @ try std.testing.expect(Keywords.isKeyword("@custom")); try std.testing.expect(!Keywords.isKeyword("context")); // No @ try std.testing.expect(!Keywords.isKeyword("")); // Empty try std.testing.expect(Keywords.isKeyword("@")); // Just @ is still a keyword pattern}
test "Context term lookup" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator();
var ctx = Context.init(allocator);
// Add a term const def = TermDefinition{ .iri = try allocator.dupe(u8, "http://schema.org/name"), .prefix = false, .type_mapping = null, .language = null, .direction = null, .container = .none, .nest = null, .index = null, .local_context = null, .protected = false, .reverse = false, }; try ctx.terms.put(try allocator.dupe(u8, "name"), def);
// Lookup existing term const found = ctx.terms.get("name"); try std.testing.expect(found != null); try std.testing.expectEqualStrings("http://schema.org/name", found.?.iri.?);
// Lookup non-existent term const not_found = ctx.terms.get("unknown"); try std.testing.expect(not_found == null);}
test "Processor expand empty object" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator();
var processor = Processor.init(allocator, .{}); var empty_members = [_]json.Member{}; const result = try processor.expand(.{ .obj = &empty_members });
try std.testing.expect(result == .obj); try std.testing.expectEqual(@as(usize, 0), result.obj.len);}
test "Processor expand array of values" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator();
var processor = Processor.init(allocator, .{}); var values = [_]json.Value{ .null, .{ .bool = true }, .{ .bool = false } }; const result = try processor.expand(.{ .arr = &values });
try std.testing.expect(result == .arr);}
// =============================================================================// DoS / correctness regression tests (ASR-009, ASR-023, GAP-009, GAP-010)// =============================================================================
/// Build a `depth`-deep nested single-element array tree: [[[...value...]]]./// Allocated from `allocator` (use an arena in tests for easy cleanup).fn buildDeepArray(allocator: std.mem.Allocator, depth: usize, leaf: json.Value) !json.Value { var current = leaf; var i: usize = 0; while (i < depth) : (i += 1) { const arr = try allocator.alloc(json.Value, 1); arr[0] = current; current = .{ .arr = arr }; } return current;}
test "ASR-009: deeply nested array expand returns MaxDepthExceeded (no crash)" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator();
// Tree far deeper than the default max_depth (256). const deep = try buildDeepArray(allocator, 5000, .{ .str = "leaf" });
var processor = Processor.init(allocator, .{}); defer processor.deinit();
try std.testing.expectError(Error.MaxDepthExceeded, processor.expand(deep));}
test "ASR-009: max_depth is configurable and bounds expansion" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator();
// 20-deep tree. With a generous cap it expands; with a tiny cap it fails. const deep = try buildDeepArray(allocator, 20, .{ .str = "leaf" });
{ var processor = Processor.init(allocator, .{ .max_depth = 1000 }); defer processor.deinit(); const ok = try processor.expand(deep); try std.testing.expect(ok == .arr); } { var processor = Processor.init(allocator, .{ .max_depth = 4 }); defer processor.deinit(); try std.testing.expectError(Error.MaxDepthExceeded, processor.expand(deep)); }}
test "ASR-009: deeply nested frame input returns MaxDepthExceeded (no crash)" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator();
const deep = try buildDeepArray(allocator, 5000, .{ .str = "leaf" });
var processor = Processor.init(allocator, .{}); defer processor.deinit();
// Frame expands the document first, so the depth guard trips during expand. var frame_members = [_]json.Member{}; try std.testing.expectError( Error.MaxDepthExceeded, processor.frame(deep, .{ .obj = &frame_members }), );}
// Test document loader that always returns the SAME url it was asked for as a// string context, producing a self-referential context resolution cycle.fn selfReferentialLoader(url: []const u8) ?json.Value { return .{ .str = url };}
test "ASR-009: self-referential @context returns RecursiveContext (no infinite loop)" { // Use the leak-detecting testing allocator (no arena): this also verifies // the cycle-guard cleanup path (resolving_contexts key dup/remove) and the // error unwind leak nothing. const allocator = std.testing.allocator;
var processor = Processor.init(allocator, .{ .document_loader = selfReferentialLoader, }); defer processor.deinit();
// Document whose @context is a URL the loader resolves back to itself. var doc_members = [_]json.Member{ .{ .k = "@context", .v = .{ .str = "https://example.org/ctx.jsonld" } }, .{ .k = "name", .v = .{ .str = "Alice" } }, };
try std.testing.expectError( Error.RecursiveContext, processor.expand(.{ .obj = &doc_members }), );}
test "GAP-010: URL @context with no document loader returns ContextNotLoaded" { // Leak-detecting allocator: error path must free everything it allocated. const allocator = std.testing.allocator;
// No document_loader configured. var processor = Processor.init(allocator, .{}); defer processor.deinit();
var doc_members = [_]json.Member{ .{ .k = "@context", .v = .{ .str = "https://example.org/ctx.jsonld" } }, .{ .k = "name", .v = .{ .str = "Alice" } }, };
try std.testing.expectError( Error.ContextNotLoaded, processor.expand(.{ .obj = &doc_members }), );}
// Loader that returns a real object context for a known import URL.// Backed by static storage so it owns no heap memory (matches how the real// `document_loader` contract works: the loader owns the returned value).var import_ctx_members = [_]json.Member{ .{ .k = "name", .v = .{ .str = "http://schema.org/name" } },};fn importLoader(url: []const u8) ?json.Value { if (std.mem.eql(u8, url, "https://example.org/base.jsonld")) { return .{ .obj = &import_ctx_members }; } return null;}
test "GAP-009: @import is resolved and merged (not silently dropped)" { // Use the leak-detecting testing allocator (no arena): the previous arena // masked any per-allocation leak in the @import context merge/move path // (createTermDefinition, processContext merge). Running raw guards those // move/merge semantics -- every duped key/value must be owned and freed. const allocator = std.testing.allocator;
var processor = Processor.init(allocator, .{ .document_loader = importLoader, }); defer processor.deinit();
// @context that imports another context and adds nothing else. var ctx_members = [_]json.Member{ .{ .k = "@import", .v = .{ .str = "https://example.org/base.jsonld" } }, }; var doc_members = [_]json.Member{ .{ .k = "@context", .v = .{ .obj = &ctx_members } }, .{ .k = "name", .v = .{ .str = "Alice" } }, };
const result = try processor.expand(.{ .obj = &doc_members }); defer freeValue(allocator, result);
// The imported context defines `name` -> http://schema.org/name, so the // expanded object must contain that IRI as a key (proving @import merged). try std.testing.expect(result == .obj); var found = false; for (result.obj) |m| { if (std.mem.eql(u8, m.k, "http://schema.org/name")) found = true; } try std.testing.expect(found);}
test "GAP-009: @import with no loader returns ContextImportFailed (not dropped)" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator();
// No loader configured: @import cannot be resolved and must error rather // than be silently skipped. var processor = Processor.init(allocator, .{}); defer processor.deinit();
var ctx_members = [_]json.Member{ .{ .k = "@import", .v = .{ .str = "https://example.org/base.jsonld" } }, }; var doc_members = [_]json.Member{ .{ .k = "@context", .v = .{ .obj = &ctx_members } }, .{ .k = "name", .v = .{ .str = "Alice" } }, };
try std.testing.expectError( Error.ContextImportFailed, processor.expand(.{ .obj = &doc_members }), );}
test "NEW-004: array @context expansion does not leak a Context per call" { // Leak-detecting allocator (NOT an arena): before the fix the `.arr` branch // of processContext orphaned its seed clone every call (the `if (i > 0)` // guard skipped freeing iteration 0's seed), leaking one Context. This must // be clean now. const allocator = std.testing.allocator;
var processor = Processor.init(allocator, .{}); defer processor.deinit();
// Array @context with two object entries -> two loop iterations, each // superseding the prior live context. All seeds must be freed exactly once. var ctx0 = [_]json.Member{.{ .k = "name", .v = .{ .str = "http://schema.org/name" } }}; var ctx1 = [_]json.Member{.{ .k = "age", .v = .{ .str = "http://schema.org/age" } }}; var ctx_arr = [_]json.Value{ .{ .obj = &ctx0 }, .{ .obj = &ctx1 } }; var doc_members = [_]json.Member{ .{ .k = "@context", .v = .{ .arr = &ctx_arr } }, .{ .k = "name", .v = .{ .str = "x" } }, };
const result = try processor.expand(.{ .obj = &doc_members }); defer freeValue(allocator, result);
// The array context defines `name` -> http://schema.org/name, so expansion // must rename the `name` key to that IRI (proves the array was applied). try std.testing.expect(result == .obj); var found = false; for (result.obj) |m| { if (std.mem.eql(u8, m.k, "http://schema.org/name")) found = true; } try std.testing.expect(found);}
test "NEW-004: single-object @context control is leak-free" { // Control: a single-object @context never hit the `.arr` seed-orphan bug; // this guards against a regression in the shared clone/free ownership. const allocator = std.testing.allocator;
var processor = Processor.init(allocator, .{}); defer processor.deinit();
var ctx_members = [_]json.Member{.{ .k = "name", .v = .{ .str = "http://schema.org/name" } }}; var doc_members = [_]json.Member{ .{ .k = "@context", .v = .{ .obj = &ctx_members } }, .{ .k = "name", .v = .{ .str = "x" } }, };
const result = try processor.expand(.{ .obj = &doc_members }); defer freeValue(allocator, result); try std.testing.expect(result == .obj);}
test "NEW-004: string/URL @context expansion does not leak the seed clone" { // Leak-detecting allocator: the `.str` branch returned the recursive call's // result while orphaning its seed clone `result`. With a loader configured, // a URL-valued @context must expand cleanly with no leak. const allocator = std.testing.allocator;
// `importLoader` resolves https://example.org/base.jsonld -> object context // {name -> http://schema.org/name} (defined above for the GAP-009 tests). var processor = Processor.init(allocator, .{ .document_loader = importLoader, }); defer processor.deinit();
var doc_members = [_]json.Member{ .{ .k = "@context", .v = .{ .str = "https://example.org/base.jsonld" } }, .{ .k = "name", .v = .{ .str = "x" } }, };
const result = try processor.expand(.{ .obj = &doc_members }); defer freeValue(allocator, result);
try std.testing.expect(result == .obj); var found = false; for (result.obj) |m| { if (std.mem.eql(u8, m.k, "http://schema.org/name")) found = true; } try std.testing.expect(found);}
test "NEW-004: array @context error path frees live context without double-free" { // A two-element array @context where the SECOND entry is an unresolvable // URL-valued context (no loader) forces an error AFTER the first iteration // already freed the seed in-loop. The function-level errdefer must free only // the still-live context (the one produced by iteration 0), not the freed // seed -- proving no double-free and no leak on the error unwind. const allocator = std.testing.allocator;
var processor = Processor.init(allocator, .{}); // no document_loader defer processor.deinit();
var ctx0 = [_]json.Member{.{ .k = "name", .v = .{ .str = "http://schema.org/name" } }}; var ctx_arr = [_]json.Value{ .{ .obj = &ctx0 }, .{ .str = "https://example.org/missing.jsonld" }, // unresolvable -> error }; var doc_members = [_]json.Member{ .{ .k = "@context", .v = .{ .arr = &ctx_arr } }, .{ .k = "name", .v = .{ .str = "x" } }, };
try std.testing.expectError( Error.ContextNotLoaded, processor.expand(.{ .obj = &doc_members }), );}
test "ASR-023: blank node ids are per-instance and deterministic across runs" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator();
// Two independent processor instances must produce identical blank-node // numbering, i.e. both start their counter at 0 (no shared global state / // no continuously-incrementing module-global counter). var p1 = Processor.init(allocator, .{}); defer p1.deinit(); const id1a = try p1.generateBlankNodeId(); const id1b = try p1.generateBlankNodeId();
var p2 = Processor.init(allocator, .{}); defer p2.deinit(); const id2a = try p2.generateBlankNodeId(); const id2b = try p2.generateBlankNodeId();
// Each fresh instance restarts numbering deterministically. try std.testing.expectEqualStrings("_:b0", id1a); try std.testing.expectEqualStrings("_:b1", id1b); try std.testing.expectEqualStrings("_:b0", id2a); try std.testing.expectEqualStrings("_:b1", id2b);}
// =============================================================================// NASR-002: depth-limit error path must not leak partial work//// These build a deeply nested caller-owned `Value` tree with the leak-detecting// testing allocator (and free it themselves), then feed it to a Processor that// ALSO uses the testing allocator. The depth guard trips mid-build, so the// processor's recursive builders accumulate a partial prefix of duped keys and// expanded child subtrees before unwinding. The testing allocator fails the// test if that partial work is not freed on the error path.// =============================================================================
/// Build a `depth`-deep nested single-member object tree, all strings duped/// from `allocator` so each level owns real heap key/value allocations:/// {"k": {"k": {"k": ... {"leaf": "v"} ... }}}/// Free with `freeValue(allocator, ...)`.fn buildDeepObject(allocator: std.mem.Allocator, depth: usize) !json.Value { var current: json.Value = .{ .str = try allocator.dupe(u8, "leaf-value") }; var i: usize = 0; while (i < depth) : (i += 1) { const members = try allocator.alloc(json.Member, 1); members[0] = .{ .k = try allocator.dupe(u8, "nestedKey"), .v = current, }; current = .{ .obj = members }; } return current;}
test "NASR-002: deep object expand error path frees partial work (no leak)" { // No arena: testing.allocator detects any leak in the processor's internal // partial expansion when MaxDepthExceeded unwinds. const allocator = std.testing.allocator;
const deep = try buildDeepObject(allocator, 600); defer freeValue(allocator, deep);
var processor = Processor.init(allocator, .{}); defer processor.deinit();
try std.testing.expectError(Error.MaxDepthExceeded, processor.expand(deep));}
test "NASR-002: deep array expand error path frees partial work (no leak)" { const allocator = std.testing.allocator;
// Each array level wraps an object so expandInternal's .arr branch // accumulates already-expanded object subtrees before tripping the guard. const inner = try buildDeepObject(allocator, 1); const deep = try buildDeepArray(allocator, 600, inner); defer freeValue(allocator, deep);
var processor = Processor.init(allocator, .{}); defer processor.deinit();
try std.testing.expectError(Error.MaxDepthExceeded, processor.expand(deep));}
test "NASR-002: deep object compact error path frees partial work (no leak)" { const allocator = std.testing.allocator;
const deep = try buildDeepObject(allocator, 600); defer freeValue(allocator, deep);
var processor = Processor.init(allocator, .{}); defer processor.deinit();
// An empty object context: compact must expand first (which trips the guard) // OR reach compactInternal; either way no partial work may leak. var ctx_members = [_]json.Member{}; try std.testing.expectError( Error.MaxDepthExceeded, processor.compact(deep, .{ .obj = &ctx_members }), );}
test "NASR-002: deep object frame error path frees partial work (no leak)" { const allocator = std.testing.allocator;
const deep = try buildDeepObject(allocator, 600); defer freeValue(allocator, deep);
var processor = Processor.init(allocator, .{}); defer processor.deinit();
var frame_members = [_]json.Member{}; try std.testing.expectError( Error.MaxDepthExceeded, processor.frame(deep, .{ .obj = &frame_members }), );}
// =============================================================================// NQUAL-007: a non-string @import must not be silently dropped// =============================================================================
test "NQUAL-007: non-string @import returns ContextImportFailed (not dropped)" { const allocator = std.testing.allocator;
var processor = Processor.init(allocator, .{}); defer processor.deinit();
// @import set to a non-string (here an object) rather than a string IRI. // This must error rather than silently `continue` past the import. var import_obj = [_]json.Member{}; var ctx_members = [_]json.Member{ .{ .k = "@import", .v = .{ .obj = &import_obj } }, }; var doc_members = [_]json.Member{ .{ .k = "@context", .v = .{ .obj = &ctx_members } }, .{ .k = "name", .v = .{ .str = "Alice" } }, };
try std.testing.expectError( Error.ContextImportFailed, processor.expand(.{ .obj = &doc_members }), );}
// =============================================================================// NASR/OOM: createTermDefinition partial-build leak guard//// `createTermDefinition` dupes @id/@type/@language into a TermDefinition// incrementally, then dupes a key and inserts into the term map. Before the// errdefer fix, an allocation failure partway through leaked the already-duped// fields (and a failed map insert leaked the key). This test sweeps the// allocation fail index with std.testing.FailingAllocator over the// leak-detecting testing allocator: at every failure point the partial work// must be fully freed, so the testing allocator reports no leak.// =============================================================================
test "NASR/OOM: createTermDefinition frees partial work on allocation failure (no leak)" { var saw_oom = false; var fail_index: usize = 0; // The full-object path dupes @id, @type, @language, then the key, then // inserts (the map's first put also allocates its backing store), so a small // sweep covers every allocation in the build. while (fail_index < 24) : (fail_index += 1) { var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = fail_index, }); const fa = failing.allocator();
// Processor/Context init perform no allocation until first map insert, // so they are safe to build directly on the failing allocator. var processor = Processor.init(fa, .{}); defer processor.deinit();
var context = Context.init(fa); defer context.deinit();
// A full @id/@type/@language object definition exercises every // incremental dupe in the build path. var def_members = [_]json.Member{ .{ .k = Keywords.id, .v = .{ .str = "http://example.org/term" } }, .{ .k = Keywords.type_kw, .v = .{ .str = "@id" } }, .{ .k = Keywords.language, .v = .{ .str = "en" } }, };
const res = processor.createTermDefinition(&context, "myTerm", .{ .obj = &def_members }); if (res) |_| { // Build completed before the fail index: term owned by `context`, // freed by context.deinit(). Nothing else to do. } else |err| { try std.testing.expectEqual(error.OutOfMemory, err); saw_oom = true; // On the error path the partial fields must already be freed by the // errdefer; context holds no term for "myTerm". The testing allocator // (backing `failing`) will fail this test at teardown if anything // duped before the failure leaked. } } // Prove the sweep actually exercised at least one OOM unwind. try std.testing.expect(saw_oom);}