From 51b015098b09d444d144579b2bf3f4a4b37233f6 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sat, 24 Oct 2026 15:45:49 +0300 Subject: [PATCH 1/4] feat(id): add GTS pattern engine for identifier matching and coverage Port the reference pattern engine (README section 10) and use it for OP#1 ID validation, OP#3 parsing, OP#4 matching and query wildcards. It enforces unsigned 32-bit version bounds, the 1024-char limit and the single-segment-instance rule, and decides coverage for pattern candidates. Signed-off-by: Artifizer --- Gts.Application/GtsHttpApiExtensions.cs | 73 ++-- Gts.Application/GtsSegmentDto.cs | 24 +- Gts.Cli/Program.cs | 113 ++---- Gts.Store/GtsQuery.cs | 3 +- Gts/GtsPattern.cs | 493 ++++++++++++++++++++++++ 5 files changed, 558 insertions(+), 148 deletions(-) create mode 100644 Gts/GtsPattern.cs diff --git a/Gts.Application/GtsHttpApiExtensions.cs b/Gts.Application/GtsHttpApiExtensions.cs index 3448a8f..c09aa73 100644 --- a/Gts.Application/GtsHttpApiExtensions.cs +++ b/Gts.Application/GtsHttpApiExtensions.cs @@ -257,20 +257,11 @@ public static WebApplication MapGtsApi(this WebApplication app, GtsRegistry regi { var id = gts_id ?? ""; var isWildcard = id.Contains('*', StringComparison.Ordinal); - if (isWildcard) - { - if (id.Count(c => c == '*') != 1 || !id.EndsWith('*')) - return Results.Json(new { id, valid = false, error = "Invalid wildcard pattern", is_wildcard = true }); - var pr = GtsId.TryParsePattern(id, out var pat); - if (pr && pat is not null) - return Results.Json(new { id, valid = true, error = "", is_wildcard = true }); - return Results.Json(new { id, valid = false, error = "Invalid wildcard pattern", is_wildcard = true }); - } - - if (GtsId.TryParse(id, out var parsed) && parsed is not null && (parsed.IsType || parsed.Segments.Count > 1)) - return Results.Json(new { id, valid = true, error = "", is_wildcard = false }); - - return Results.Json(new { id, valid = false, error = "Invalid GTS id", is_wildcard = false }); + // The §10 engine enforces syntax, spelling, the 1024-char limit, unsigned 32-bit version + // bounds, and the issue-#37 single-segment-instance rule for both concrete ids and patterns. + if (GtsPattern.TryParse(id, out _)) + return Results.Json(new { id, valid = true, error = "", is_wildcard = isWildcard }); + return Results.Json(new { id, valid = false, error = isWildcard ? "Invalid wildcard pattern" : "Invalid GTS id", is_wildcard = isWildcard }); }); app.MapPost("/extract-id", async (HttpRequest req) => @@ -293,60 +284,40 @@ public static WebApplication MapGtsApi(this WebApplication app, GtsRegistry regi { var id = gts_id ?? ""; var isWildcard = id.Contains('*', StringComparison.Ordinal); - if (isWildcard) - { - if (id.Count(c => c == '*') == 1 && id.EndsWith('*') && GtsId.TryParsePattern(id, out var pat) && pat is not null) - { - var wildcardIsSchema = id.EndsWith(".*", StringComparison.Ordinal) || - id.EndsWith("~*", StringComparison.Ordinal); - return Results.Json(new - { - id, - ok = true, - segments = pat.Segments.Select(GtsSegmentDto.FromSegment).ToList(), - error = "", - is_wildcard = true, - is_type = wildcardIsSchema - }); - } - - return Results.Json(new - { - id, - ok = false, - segments = Array.Empty(), - error = "Invalid pattern", - is_wildcard = true, - is_type = false - }); - } - - if (!GtsId.TryParse(id, out var gid) || gid is null) + if (!GtsPattern.TryParse(id, out var pattern)) return Results.Json(new { id, ok = false, segments = Array.Empty(), - error = "Parse error", - is_wildcard = false, + error = isWildcard ? "Invalid pattern" : "Parse error", + is_wildcard = isWildcard, is_type = false }); + var isType = isWildcard + ? id.EndsWith(".*", StringComparison.Ordinal) || id.EndsWith("~*", StringComparison.Ordinal) + : id.EndsWith('~'); return Results.Json(new { id, ok = true, - segments = gid.Segments.Select(GtsSegmentDto.FromSegment).ToList(), + segments = pattern!.Segments.Select(GtsSegmentDto.FromSegment).ToList(), error = "", - is_wildcard = false, - is_type = gid.IsType + is_wildcard = isWildcard, + is_type = isType }); }); + // OP#4 identifier matching (README §10): a concrete candidate matches when it is in the + // filter's selected set; a pattern candidate matches when the filter covers it. app.MapGet("/match-id-pattern", (string candidate, string pattern) => - GtsId.TryMatch(candidate, pattern, out var match) - ? Results.Json(new { candidate, pattern, match }) - : Results.Json(new { candidate, pattern, match = false, error = "Invalid candidate or pattern" })); + { + if (!GtsPattern.TryParse(pattern, out var filter) || !GtsPattern.TryParse(candidate, out var cand)) + return Results.Json(new { candidate, pattern, match = false, error = "Invalid candidate or pattern" }); + var match = cand!.HasWildcard ? filter!.Covers(cand) : filter!.MatchesCandidate(candidate); + return Results.Json(new { candidate, pattern, match }); + }); app.MapGet("/uuid", (string gts_id) => { diff --git a/Gts.Application/GtsSegmentDto.cs b/Gts.Application/GtsSegmentDto.cs index b394f47..5459783 100644 --- a/Gts.Application/GtsSegmentDto.cs +++ b/Gts.Application/GtsSegmentDto.cs @@ -1,18 +1,24 @@ +using System.Text.Json.Nodes; using Gts; namespace Gts.Application; -/// Maps to JSON-friendly segment objects (snake_case fields). +/// Maps a §10 to JSON-friendly segment objects (snake_case fields). public static class GtsSegmentDto { - public static object FromSegment(GtsIdSegment s) => new + /// + /// Maps a §10 to a JSON segment object. A + /// is used so ver_minor is always present (as explicit null when a segment omits the + /// minor), rather than dropped by the server's global "ignore null" serialization policy. + /// + public static object FromSegment(GtsPattern.SegmentView s) => new JsonObject { - vendor = s.Vendor, - package = s.Package, - @namespace = s.Namespace, - type = s.Type, - ver_major = s.VersionMajor, - ver_minor = s.VersionMinor, - is_type = s.IsType + ["vendor"] = s.Vendor, + ["package"] = s.Package, + ["namespace"] = s.Namespace, + ["type"] = s.Type, + ["ver_major"] = s.VersionMajor is { } major ? JsonValue.Create(major) : null, + ["ver_minor"] = s.VersionMinor is { } minor ? JsonValue.Create(minor) : null, + ["is_type"] = s.IsType }; } diff --git a/Gts.Cli/Program.cs b/Gts.Cli/Program.cs index 43d666d..a4b1f9c 100644 --- a/Gts.Cli/Program.cs +++ b/Gts.Cli/Program.cs @@ -253,42 +253,27 @@ private static int RunValidateId(string[] args) return 2; } + // The §10 engine enforces syntax, spelling, the 1024-char limit, unsigned 32-bit version + // bounds and the single-segment-instance rule for both concrete ids and patterns, matching + // the HTTP /validate-id endpoint. var isWildcard = id.Contains('*', StringComparison.Ordinal); - if (isWildcard) - { - var pr = GtsId.TryParsePattern(id, out var pat); - if (!pr || pat is null) - { - WriteJson(new - { - id, - valid = false, - is_schema = false, - is_wildcard = true, - error = $"Unable to validate GTS ID '{id}': Invalid wildcard pattern" - }); - return 0; - } - - var isSchema = id.EndsWith("~*", StringComparison.Ordinal) || id.EndsWith(".*", StringComparison.Ordinal); - WriteJson(new { id, valid = true, is_schema = isSchema, is_wildcard = true, error = "" }); - return 0; - } - - if (!GtsId.TryParse(id, out var gid) || gid is null) + if (!GtsPattern.TryParse(id, out _)) { WriteJson(new { id, valid = false, is_schema = false, - is_wildcard = false, - error = $"Unable to validate GTS ID '{id}': Invalid GTS id" + is_wildcard = isWildcard, + error = $"Unable to validate GTS ID '{id}': {(isWildcard ? "Invalid wildcard pattern" : "Invalid GTS id")}" }); return 0; } - WriteJson(new { id, valid = true, is_schema = gid.IsType, is_wildcard = false, error = "" }); + var isSchema = isWildcard + ? id.EndsWith("~*", StringComparison.Ordinal) || id.EndsWith(".*", StringComparison.Ordinal) + : id.EndsWith('~'); + WriteJson(new { id, valid = true, is_schema = isSchema, is_wildcard = isWildcard, error = "" }); return 0; } @@ -302,56 +287,30 @@ private static int RunParseId(string[] args) } var isWildcard = id.Contains('*', StringComparison.Ordinal); - if (isWildcard) - { - if (!GtsId.TryParsePattern(id, out var pat) || pat is null) - { - WriteJson(new - { - id, - ok = false, - is_wildcard = true, - is_schema = false, - segments = (object?)null, - error = "Invalid pattern" - }); - return 0; - } - - var wildcardIsSchema = id.EndsWith(".*", StringComparison.Ordinal) || id.EndsWith("~*", StringComparison.Ordinal); - WriteJson(new - { - id, - ok = true, - is_wildcard = true, - is_schema = wildcardIsSchema, - segments = pat.Segments.Select(GtsSegmentDto.FromSegment).ToList(), - error = "" - }); - return 0; - } - - if (!GtsId.TryParse(id, out var gid) || gid is null) + if (!GtsPattern.TryParse(id, out var pattern)) { WriteJson(new { id, ok = false, - is_wildcard = false, + is_wildcard = isWildcard, is_schema = false, segments = (object?)null, - error = "Parse error" + error = isWildcard ? "Invalid pattern" : "Parse error" }); return 0; } + var isSchema = isWildcard + ? id.EndsWith(".*", StringComparison.Ordinal) || id.EndsWith("~*", StringComparison.Ordinal) + : id.EndsWith('~'); WriteJson(new { id, ok = true, - is_wildcard = false, - is_schema = gid.IsType, - segments = gid.Segments.Select(GtsSegmentDto.FromSegment).ToList(), + is_wildcard = isWildcard, + is_schema = isSchema, + segments = pattern!.Segments.Select(GtsSegmentDto.FromSegment).ToList(), error = "" }); return 0; @@ -367,35 +326,17 @@ private static int RunMatchIdPattern(string[] args) return 2; } - try - { - if (candidate.Contains('*', StringComparison.Ordinal)) - { - if (!GtsId.TryParsePattern(candidate, out var cPat) || cPat is null || - !GtsId.TryParsePattern(pattern, out var pPat) || pPat is null) - { - WriteJson(new { candidate, pattern, match = false, error = "invalid pattern" }); - return 0; - } - - WriteJson(new { candidate, pattern, match = cPat.Matches(pPat), error = "" }); - return 0; - } - - if (!GtsId.TryParse(candidate, out var cId) || cId is null) - { - WriteJson(new { candidate, pattern, match = false, error = "invalid candidate" }); - return 0; - } - - WriteJson(new { candidate, pattern, match = cId.Matches(pattern), error = "" }); - return 0; - } - catch (Exception ex) + // OP#4 (README §10): a concrete candidate matches when it is in the filter's selected set; + // a pattern candidate matches when the filter covers it. Shared with HTTP /match-id-pattern. + if (!GtsPattern.TryParse(pattern, out var filter) || !GtsPattern.TryParse(candidate, out var cand)) { - WriteJson(new { candidate, pattern, match = false, error = ex.Message }); + WriteJson(new { candidate, pattern, match = false, error = "invalid candidate or pattern" }); return 0; } + + var match = cand!.HasWildcard ? filter!.Covers(cand) : filter!.MatchesCandidate(candidate); + WriteJson(new { candidate, pattern, match, error = "" }); + return 0; } private static int RunUuid(string[] args) diff --git a/Gts.Store/GtsQuery.cs b/Gts.Store/GtsQuery.cs index aa6e19d..8db7f1e 100644 --- a/Gts.Store/GtsQuery.cs +++ b/Gts.Store/GtsQuery.cs @@ -150,8 +150,7 @@ public static List Execute( internal static bool IdMatches(GtsId id, GtsParsedQuery q) => q.IsWildcard - ? id.Matches(q.BasePattern) && (!q.BasePattern.EndsWith("~*", StringComparison.Ordinal) || - id.Segments.Count > q.BasePattern[..^1].Count(character => character == '~')) + ? GtsPattern.TryParse(q.BasePattern, out var pattern) && pattern!.Matches(id) : string.Equals(id.Id, q.BasePattern, StringComparison.Ordinal); private static Dictionary ParseFilters(string? filterPart) diff --git a/Gts/GtsPattern.cs b/Gts/GtsPattern.cs new file mode 100644 index 0000000..1d0d957 --- /dev/null +++ b/Gts/GtsPattern.cs @@ -0,0 +1,493 @@ +using System.Globalization; + +namespace Gts; + +/// +/// A GTS identifier match pattern (README §10): a validated GTS identifier that may end in a single +/// trailing * wildcard, but may also be fully concrete. A zero-wildcard pattern is not an +/// exact match — a base Type ID used as a pattern also matches everything derived from or +/// instantiated under it, with minor-version flexibility (so …type.v1~ behaves as the +/// implicit envelope …type.v1~*). +/// +/// This is a direct port of the Rust reference (gts-id/src/parse.rs, gts_id_segment.rs, +/// gts_id_pattern.rs). It supersedes the legacy regex-based matching for GTS reference and +/// pattern keywords, implementing identifier matching () and pattern +/// coverage (), including the unsigned 32-bit version bounds and the +/// v1.* / v1~* / v* distinctions. +/// +public sealed class GtsPattern +{ + /// Maximum allowed length of a GTS identifier or pattern string. + public const int MaxLength = 1024; + + private const string Prefix = "gts."; + + /// Four one-letter names, four dots and v0: the shortest instance segment, a.a.a.a.v0. + private const int MinSegmentLen = 10; + + private readonly string _pattern; + private readonly IReadOnlyList _segments; + + private GtsPattern(string pattern, IReadOnlyList segments) + { + _pattern = pattern; + _segments = segments; + } + + /// The validated pattern string. + public string Pattern => _pattern; + + /// Whether this pattern ends in a * wildcard (otherwise it is a concrete identifier). + public bool HasWildcard => _pattern.Contains('*'); + + /// A read-only view of one parsed segment (unspecified name tokens are null). + public readonly record struct SegmentView( + string? Vendor, string? Package, string? Namespace, string? Type, + long? VersionMajor, long? VersionMinor, bool IsType, bool IsWildcard); + + /// The parsed segments, for inspection (OP#1/OP#3 parse output). + public IReadOnlyList Segments => _segments + .Select(s => new SegmentView( + s.Vendor.Length == 0 ? null : s.Vendor, + s.Package.Length == 0 ? null : s.Package, + s.Namespace.Length == 0 ? null : s.Namespace, + s.TypeName.Length == 0 ? null : s.TypeName, + s.VerMajor, s.VerMinor, s.IsType, s.IsWildcard)) + .ToList(); + + /// Parses a §10 pattern (concrete identifier or trailing * wildcard). + public static bool TryParse(string? text, out GtsPattern? pattern) + { + pattern = null; + if (text is null) + return false; + if (!TryParseSegments(text, allowWildcards: true, out var segments)) + return false; + pattern = new GtsPattern(text, segments); + return true; + } + + /// Whether is a valid §10 pattern. + public static bool IsValid(string? text) => TryParse(text, out _); + + /// Whether (a concrete identifier) is selected by this pattern (id ∈ M(this)). + public bool Matches(GtsId id) + { + ArgumentNullException.ThrowIfNull(id); + return TryParseSegments(id.Id, allowWildcards: false, out var candidate) && MatchesSegments(candidate); + } + + /// Whether the concrete identifier string is selected by this pattern. + public bool MatchesCandidate(string candidate) => + TryParseSegments(candidate, allowWildcards: false, out var segments) && MatchesSegments(segments); + + /// Whether this pattern covers : M(other) ⊆ M(this). + public bool Covers(GtsPattern other) + { + ArgumentNullException.ThrowIfNull(other); + var value = Selection.Of(other); + var shortest = value.ShortestSelected(); + // A valid pattern can select no canonical identifier (every selection would exceed the + // length limit); then M(other) is empty and every pattern covers it. + return shortest > MaxLength || CoversNonempty(Selection.Of(this), value, shortest == MaxLength); + } + + // ---- matching (port of GtsIdPattern::matches_views) ------------------------------------ + + private bool MatchesSegments(IReadOnlyList candidate) + { + var pattern = _segments; + // A final bare `~*` may match an empty chain suffix. A wildcard that already specifies part + // of the next segment (for example `~abc.*`) still requires that segment to exist. + var matchesEmptySuffix = pattern.Count > 0 && pattern[^1].IsWildcard && pattern[^1].Raw == "*"; + var requiredLen = pattern.Count - (matchesEmptySuffix ? 1 : 0); + if (requiredLen > candidate.Count) + return false; + + for (var i = 0; i < pattern.Count; i++) + { + if (i == candidate.Count) + return matchesEmptySuffix && i == pattern.Count - 1; + var p = pattern[i]; + var c = candidate[i]; + + if (p.IsWildcard) + { + if (p.Vendor.Length != 0 && p.Vendor != c.Vendor) return false; + if (p.Package.Length != 0 && p.Package != c.Package) return false; + if (p.Namespace.Length != 0 && p.Namespace != c.Namespace) return false; + if (p.TypeName.Length != 0 && p.TypeName != c.TypeName) return false; + if (p.VerMajor is { } pm && c.VerMajor != pm) return false; + // `v1.*` requires an explicit minor in the concrete id. + if (p.VerMajor is not null && c.VerMinor is null) return false; + if (p.VerMinor is { } pmin && c.VerMinor != pmin) return false; + return true; + } + + // Non-wildcard UUID tail — compare the raw UUID string. + if (p.UuidTail is not null && p.UuidTail != c.UuidTail) return false; + if (p.Vendor != c.Vendor) return false; + if (p.Package != c.Package) return false; + if (p.Namespace != c.Namespace) return false; + if (p.TypeName != c.TypeName) return false; + if (p.VerMajor != c.VerMajor) return false; + if (p.VerMinor is { } min && c.VerMinor != min) return false; + if (p.IsType != c.IsType) return false; + } + return true; + } + + // ---- coverage (port of GtsIdPattern::covers) ------------------------------------------- + + private abstract record Selection + { + internal sealed record Instance(IReadOnlyList Types, Segment Tail, int Length) : Selection; + + internal sealed record Prefixed(IReadOnlyList Types, Segment? Next) : Selection; + + internal static Selection Of(GtsPattern pattern) + { + var segments = pattern._segments; + var end = segments.Count - 1; + var last = segments[end]; + if (last.IsWildcard) + return new Prefixed(Slice(segments, 0, end), last.Raw != "*" ? last : null); + if (last.IsType) + return new Prefixed(segments, null); + return new Instance(Slice(segments, 0, end), last, pattern._pattern.Length); + } + + internal int ShortestSelected() + { + switch (this) + { + case Instance instance: + return instance.Length; + case Prefixed prefixed: + var chain = Prefix.Length + prefixed.Types.Sum(s => s.Raw.Length); + if (prefixed.Next is null && prefixed.Types.Count != 0) + return chain; + var segment = prefixed.Next is { } next ? ShortestPartial(next) : MinSegmentLen; + // A first segment must end in the type marker. + return chain + segment + (prefixed.Types.Count == 0 ? 1 : 0); + default: + return 0; + } + } + } + + private static int ShortestPartial(Segment partial) + { + if (partial.VerMajor is not null) + // In `v1.*`, replacing `*` with the shortest minor (`0`) keeps the length. + return partial.Raw.Length; + // Missing names are one character; `.v0` and the name separators add six. + return Math.Max(partial.Vendor.Length, 1) + Math.Max(partial.Package.Length, 1) + + Math.Max(partial.Namespace.Length, 1) + Math.Max(partial.TypeName.Length, 1) + 6; + } + + private static bool CoversNonempty(Selection operand, Selection value, bool atLimit) + { + switch (operand, value) + { + case (Selection.Instance a, Selection.Instance b): + if (!SameChainCovers(a.Types, b.Types)) + return false; + return (a.Tail.UuidTail, b.Tail.UuidTail) switch + { + (not null, not null) => a.Tail.UuidTail == b.Tail.UuidTail, + (null, null) => SegmentCovers(a.Tail, b.Tail), + _ => false, + }; + + case (Selection.Instance a, Selection.Prefixed { Next: { } partial } b) when atLimit: + // Only one-digit instance minors fit; appending a type marker or another chain + // segment would exceed the length limit. + return SameChainCovers(a.Types, b.Types) + && a.Tail.UuidTail is null + && a.Tail.VerMinor is null + && SameNames(a.Tail, partial) + && partial.VerMajor == a.Tail.VerMajor; + + case (Selection.Instance, Selection.Prefixed): + return false; + + case (Selection.Prefixed p, Selection.Instance inst): + return inst.Types.Count >= p.Types.Count + && p.Types.Where((t, i) => SegmentCovers(t, inst.Types[i])).Count() == p.Types.Count + && (p.Next is null || PartialAdmits(p.Next, p.Types.Count < inst.Types.Count ? inst.Types[p.Types.Count] : inst.Tail)); + + case (Selection.Prefixed a, Selection.Prefixed b): + return PrefixedCovers(a.Types, a.Next, b.Types, b.Next); + + default: + return false; + } + } + + private static bool SameNames(Segment a, Segment b) => + a.Vendor == b.Vendor && a.Package == b.Package && a.Namespace == b.Namespace && a.TypeName == b.TypeName; + + private static bool SegmentCovers(Segment a, Segment b) => + SameNames(a, b) && a.VerMajor == b.VerMajor && (a.VerMinor is null || a.VerMinor == b.VerMinor); + + private static bool SameChainCovers(IReadOnlyList a, IReadOnlyList b) => + a.Count == b.Count && !a.Where((t, i) => !SegmentCovers(t, b[i])).Any(); + + private static bool PartialNamesCover(Segment a, Segment b) => + (a.Vendor.Length == 0 || a.Vendor == b.Vendor) + && (a.Package.Length == 0 || a.Package == b.Package) + && (a.Namespace.Length == 0 || a.Namespace == b.Namespace) + && (a.TypeName.Length == 0 || a.TypeName == b.TypeName); + + private static bool PartialAdmits(Segment partial, Segment segment) => + PartialNamesCover(partial, segment) + && (partial.VerMajor is not { } major || (segment.VerMajor == major && segment.VerMinor is not null)); + + private static bool PartialCovers(Segment a, Segment b) => + PartialNamesCover(a, b) && (a.VerMajor is not { } major || b.VerMajor == major); + + private static bool PrefixedCovers(IReadOnlyList a, Segment? aNext, IReadOnlyList b, Segment? bNext) + { + if (a.Count > b.Count) + { + // A first-segment minor wildcard selects types contained in a bare type scope with the + // same names and major and an omitted minor. + if (a.Count == 1 && aNext is null && b.Count == 0 && bNext is { } partial) + return a[0].VerMinor is null && SameNames(a[0], partial) && a[0].VerMajor == partial.VerMajor; + return false; + } + for (var i = 0; i < a.Count; i++) + if (!SegmentCovers(a[i], b[i])) + return false; + if (aNext is not { } next) + return true; + if (a.Count < b.Count) + return PartialAdmits(next, b[a.Count]); + return bNext is { } other && PartialCovers(next, other); + } + + private static IReadOnlyList Slice(IReadOnlyList segments, int start, int end) + { + var list = new List(end - start); + for (var i = start; i < end; i++) + list.Add(segments[i]); + return list; + } + + // ---- parsing (port of parse.rs + gts_id_segment.rs) ------------------------------------ + + private sealed record Segment( + string Raw, string Vendor, string Package, string Namespace, string TypeName, + long? VerMajor, long? VerMinor, bool IsWildcard) + { + public bool IsType => Raw.EndsWith('~'); + public string? UuidTail => IsUuid(Raw) ? Raw : null; + } + + private static bool TryParseSegments(string id, bool allowWildcards, out IReadOnlyList segments) + { + segments = Array.Empty(); + if (!id.StartsWith(Prefix, StringComparison.Ordinal)) + return false; + if (id != id.ToLowerInvariant() || id.Length > MaxLength) + return false; + + if (allowWildcards) + { + var stars = id.Count(c => c == '*'); + if (stars > 1 || (stars == 1 && !id.EndsWith('*'))) + return false; + } + + var remainder = id[Prefix.Length..]; + var tildeParts = remainder.Split('~'); + + var last = tildeParts.Length > 0 ? tildeParts[^1] : ""; + var uuidTail = IsUuid(last) && tildeParts.Length >= 2 ? last : null; + + var segmentsPortion = uuidTail is not null ? id[..(id.Length - uuidTail.Length - 1)] : id; + if (segmentsPortion.Contains('-')) + return false; + + var count = tildeParts.Length - (uuidTail is not null ? 1 : 0); + var raws = new List(); + for (var i = 0; i < count; i++) + { + var part = tildeParts[i]; + var isLast = i == count - 1; + if (part.Length == 0) + { + if (!(isLast && uuidTail is null)) + return false; + } + else if (isLast && uuidTail is null) + raws.Add(part); + else + raws.Add(part + "~"); + } + if (raws.Count == 0) + return false; + + var result = new List(raws.Count + 1); + foreach (var raw in raws) + { + if (!TryParseSegment(raw, allowWildcards, out var segment)) + return false; + result.Add(segment); + } + if (uuidTail is not null) + result.Add(new Segment(uuidTail, "", "", "", "", null, null, false)); + + // Issue #37: a single-segment instance id is prohibited (wildcard/UUID-tail exempt). + if (uuidTail is null && result.Count == 1 && !result[0].IsType && !result[0].IsWildcard) + return false; + + segments = result; + return true; + } + + private static bool TryParseSegment(string raw, bool allowWildcards, out Segment segment) + { + segment = null!; + var seg = raw; + if (seg.Contains('~')) + { + if (seg.Count(c => c == '~') > 1 || !seg.EndsWith('~')) + return false; + seg = seg[..^1]; + } + + var tokens = seg.Split('.'); + if (tokens.Length > 6) + return false; + + var endsWithWildcard = allowWildcards && seg.EndsWith('*'); + if (!endsWithWildcard && tokens.Length < 5) + return false; + + // Reject an extra name token before the version, e.g. vendor.package.namespace.type.extra.v1. + if (!endsWithWildcard && tokens.Length == 6) + { + var hasWildcard = allowWildcards && tokens.Contains("*"); + if (!hasWildcard && !tokens[4].StartsWith('v') && tokens[5].StartsWith('v') && IsValidToken(tokens[4])) + return false; + } + + for (var i = 0; i < Math.Min(4, tokens.Length); i++) + { + if (allowWildcards && tokens[i] == "*") + { + if (i == tokens.Length - 1) + break; + return false; + } + if (!IsValidToken(tokens[i])) + return false; + } + + string vendor = "", package = "", ns = "", type = ""; + long? major = null, minor = null; + + if (tokens.Length > 0) + { + if (allowWildcards && tokens[0] == "*") { segment = Wildcard(raw, vendor, package, ns, type, major, minor); return true; } + vendor = tokens[0]; + } + if (tokens.Length > 1) + { + if (allowWildcards && tokens[1] == "*") { segment = Wildcard(raw, vendor, package, ns, type, major, minor); return true; } + package = tokens[1]; + } + if (tokens.Length > 2) + { + if (allowWildcards && tokens[2] == "*") { segment = Wildcard(raw, vendor, package, ns, type, major, minor); return true; } + ns = tokens[2]; + } + if (tokens.Length > 3) + { + if (allowWildcards && tokens[3] == "*") { segment = Wildcard(raw, vendor, package, ns, type, major, minor); return true; } + type = tokens[3]; + } + if (tokens.Length > 4) + { + if (allowWildcards && tokens[4] == "*") + { + if (4 != tokens.Length - 1) return false; + segment = Wildcard(raw, vendor, package, ns, type, major, minor); + return true; + } + // Glued version wildcard `v*` means "any version". + if (allowWildcards && tokens[4] == "v*") + { + if (4 != tokens.Length - 1) return false; + segment = Wildcard(raw, vendor, package, ns, type, major, minor); + return true; + } + if (!tokens[4].StartsWith('v')) + return false; + if (!TryParseU32Exact(tokens[4][1..], out var m)) + return false; + major = m; + } + if (tokens.Length > 5) + { + if (allowWildcards && tokens[5] == "*") + { + segment = Wildcard(raw, vendor, package, ns, type, major, minor); + return true; + } + if (!TryParseU32Exact(tokens[5], out var mn)) + return false; + minor = mn; + } + + segment = new Segment(raw, vendor, package, ns, type, major, minor, false); + return true; + } + + private static Segment Wildcard(string raw, string vendor, string package, string ns, string type, long? major, long? minor) => + new(raw, vendor, package, ns, type, major, minor, true); + + private static bool IsValidToken(string token) + { + if (token.Length == 0) + return false; + var first = token[0]; + if (first != '_' && first is < 'a' or > 'z') + return false; + foreach (var c in token) + if (c != '_' && c is < 'a' or > 'z' && c is < '0' or > '9') + return false; + return true; + } + + private static bool TryParseU32Exact(string value, out long parsed) + { + parsed = 0; + if (!uint.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var u)) + return false; + if (u.ToString(CultureInfo.InvariantCulture) != value) + return false; + parsed = u; + return true; + } + + private static bool IsUuid(string s) + { + if (s.Length != 36) + return false; + for (var i = 0; i < s.Length; i++) + { + var c = s[i]; + if (i is 8 or 13 or 18 or 23) + { + if (c != '-') + return false; + } + else if (!Uri.IsHexDigit(c)) + return false; + } + return true; + } +} From 0fc2d214004b0cec0e3a51d63dd84a83f0d9374a Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sat, 24 Oct 2026 15:46:01 +0300 Subject: [PATCH 2/4] feat(regex): enforce the GTS regular-expression profile Check every pattern, patternProperties key and format: regex value against the ECMA-262 u / RE2 profile (README section 11.0.1, ADR-0006), including targets reached only through $ref. Supported expressions are translated to reference semantics (code-point atoms, ASCII \d \w \s, $ as end of input) and matched in linear time with the non-backtracking engine, replacing the 250 ms match timeout. additionalProperties no longer compiles sibling patternProperties with the ECMAScript engine, which rejected in-profile astral ranges. Signed-off-by: Artifizer --- Gts.Store/GtsSchemaValidationService.cs | 6 + Gts.Store/Validation/GtsFormatRegistry.cs | 13 +- Gts.Store/Validation/GtsJsonPointer.cs | 4 + Gts.Store/Validation/GtsRegexCompiler.cs | 314 ++++++++++++ Gts.Store/Validation/GtsRegexKeywords.cs | 53 ++- Gts.Store/Validation/GtsRegexProfile.cs | 449 ++++++++++++++++++ .../Validation/GtsSchemaRegexValidator.cs | 207 ++++++++ .../Validation/InstanceValidationTests.cs | 6 +- .../Validation/JsonInfrastructureTests.cs | 7 +- 9 files changed, 1040 insertions(+), 19 deletions(-) create mode 100644 Gts.Store/Validation/GtsRegexCompiler.cs create mode 100644 Gts.Store/Validation/GtsRegexProfile.cs create mode 100644 Gts.Store/Validation/GtsSchemaRegexValidator.cs diff --git a/Gts.Store/GtsSchemaValidationService.cs b/Gts.Store/GtsSchemaValidationService.cs index 25a3133..9259083 100644 --- a/Gts.Store/GtsSchemaValidationService.cs +++ b/Gts.Store/GtsSchemaValidationService.cs @@ -81,6 +81,12 @@ internal async ValueTask ValidateAsync(GtsId schemaId : entity.Content; } + // Check every regular expression against the GTS profile across the reachable schema graph, + // including `$ref` targets that JSON-Schema compilation resolves only lazily (README §11.0.1). + var regexError = GtsSchemaRegexValidator.Check(document, Load); + if (regexError is not null) + return new GtsSchemaValidationResult { Ok = false, SchemaId = schemaId.Id, FailureReason = GtsValidationFailure.InvalidJsonSchema, Errors = new[] { regexError } }; + var (derivationValid, derivationErrors) = GtsSchemaDerivationValidator.ValidateAgainstRegistry(schemaId, document, Load); if (!derivationValid) return new GtsSchemaValidationResult { Ok = false, SchemaId = schemaId.Id, FailureReason = GtsValidationFailure.PrecedentIncompatible, Errors = new[] { "Derived schema is not compatible with base: " + string.Join("; ", derivationErrors) } }; diff --git a/Gts.Store/Validation/GtsFormatRegistry.cs b/Gts.Store/Validation/GtsFormatRegistry.cs index cc390d4..b6328ef 100644 --- a/Gts.Store/Validation/GtsFormatRegistry.cs +++ b/Gts.Store/Validation/GtsFormatRegistry.cs @@ -51,18 +51,7 @@ private static bool IsTime(string value) return !match.Groups[5].Success || int.Parse(match.Groups[5].Value) <= 23 && int.Parse(match.Groups[6].Value) <= 59; } - private static bool IsRegex(string value) - { - try - { - _ = new Regex(value, RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(250)); - return true; - } - catch (ArgumentException) - { - return false; - } - } + private static bool IsRegex(string value) => GtsRegexCompiler.IsSupported(value); private sealed class PredicateFormat(string key, Func predicate) : Format(key) { diff --git a/Gts.Store/Validation/GtsJsonPointer.cs b/Gts.Store/Validation/GtsJsonPointer.cs index 8468413..0460817 100644 --- a/Gts.Store/Validation/GtsJsonPointer.cs +++ b/Gts.Store/Validation/GtsJsonPointer.cs @@ -5,6 +5,10 @@ namespace Gts.Store.Validation; internal static class GtsJsonPointer { + /// Escapes a single reference token for use in a JSON Pointer (~→~0, /→~1). + internal static string Escape(string segment) => + segment.Replace("~", "~0", StringComparison.Ordinal).Replace("/", "~1", StringComparison.Ordinal); + internal static bool TryEvaluate(JsonNode? root, string reference, out JsonNode? result) { result = null; diff --git a/Gts.Store/Validation/GtsRegexCompiler.cs b/Gts.Store/Validation/GtsRegexCompiler.cs new file mode 100644 index 0000000..abf5e3b --- /dev/null +++ b/Gts.Store/Validation/GtsRegexCompiler.cs @@ -0,0 +1,314 @@ +using System.Collections.Concurrent; +using System.Text; +using System.Text.RegularExpressions; + +namespace Gts.Store.Validation; + +/// +/// Compiles GTS-profile regular expressions (README §11.0.1, ADR-0006) for matching. +/// +/// An expression is first checked against ; an out-of-profile or +/// over-bounds expression is unsupported and rejected at the schema position (never a silent +/// non-match). A supported expression is translated to the reference matching semantics and +/// compiled with , which guarantees the linear-time +/// Boolean search ADR-0006 requires and rejects exactly the lookaround/backreference constructs +/// the profile already excludes. +/// +/// Reference matching uses RE2 semantics (unanchored search; case sensitive; multiline and dot-all +/// off; $ matches end of input only, not before a final LF; atoms match code points; +/// \d, \w, \s are ASCII). No permitted deviation is declared, so the +/// expression is translated to these semantics (see Translate). +/// +internal static class GtsRegexCompiler +{ + private static readonly ConcurrentDictionary Cache = new(StringComparer.Ordinal); + + /// + /// Compiles for matching, throwing when it is unsupported or the + /// engine cannot build it. + /// + /// The expression is outside the profile or its bounds, or + /// an in-profile expression the engine could not compile (an engine failure, not a non-match). + internal static Regex Compile(string expression) + { + if (Cache.Count >= 1024) + Cache.Clear(); + return Cache.GetOrAdd(expression, static expr => + { + var reason = GtsRegexProfile.Check(expr); + if (reason is not null) + throw new GtsRegexException(GtsRegexProfile.Describe(expr, reason)); + try + { + return new Regex(Translate(expr), RegexOptions.NonBacktracking | RegexOptions.CultureInvariant); + } + catch (Exception ex) when (ex is ArgumentException or RegexParseException) + { + throw new GtsRegexException( + GtsRegexProfile.Describe(expr, $"engine failed to compile it: {ex.Message}")); + } + }); + } + + /// Whether is in the profile (as format: "regex" asserts). + internal static bool IsSupported(string expression) => GtsRegexProfile.IsSupported(expression); + + private static readonly (int Lo, int Hi)[] Digit = [('0', '9')]; + private static readonly (int Lo, int Hi)[] Word = [('0', '9'), ('A', 'Z'), ('_', '_'), ('a', 'z')]; + private static readonly (int Lo, int Hi)[] Space = [('\t', '\n'), ('\f', '\r'), (' ', ' ')]; + private static readonly (int Lo, int Hi)[] Dot = [(0, '\n' - 1), ('\n' + 1, 0x10FFFF)]; + + /// + /// Translates a profile expression to an equivalent .NET expression with reference semantics: + /// atoms match whole code points (astral characters become surrogate-pair alternations), + /// \d, \w, \s are the ASCII/RE2 sets, . matches any code point but + /// LF, and $ becomes \z. The profile guarantees the input is valid. + /// + private static string Translate(string expression) + { + var cps = ToCodePoints(expression); + var builder = new StringBuilder(expression.Length * 2); + var i = 0; + while (i < cps.Length) + { + var c = cps[i]; + switch (c) + { + case '^' or ')' or '|' or '*' or '+' or '?': + builder.Append((char)c); + i++; + break; + case '$': + builder.Append(@"\z"); + i++; + break; + case '(': + if (i + 1 < cps.Length && cps[i + 1] == '?') + { + builder.Append("(?:"); + i += 3; + } + else + { + builder.Append('('); + i++; + } + break; + case '{': + while (cps[i] != '}') + builder.Append((char)cps[i++]); + builder.Append('}'); + i++; + break; + case '.': + Emit(builder, Dot); + i++; + break; + case '[': + i++; + Emit(builder, ParseClass(cps, ref i)); + break; + case '\\': + i++; + if (Shorthand(cps[i]) is { } shorthand) + { + Emit(builder, shorthand); + i++; + } + else + { + var literal = Escape(cps, ref i); + Emit(builder, [(literal, literal)]); + } + break; + default: + Emit(builder, [(c, c)]); + i++; + break; + } + } + return builder.ToString(); + } + + /// The set of a shorthand escape letter, or null when it is not one. + private static List<(int Lo, int Hi)>? Shorthand(int c) => c switch + { + 'd' => [.. Digit], + 'D' => Complement(Digit), + 'w' => [.. Word], + 'W' => Complement(Word), + 's' => [.. Space], + 'S' => Complement(Space), + _ => null, + }; + + /// Reads a single-code-point escape starting after '\'; leaves after it. + private static int Escape(int[] cps, ref int i) + { + var c = cps[i++]; + switch (c) + { + case 'n': return '\n'; + case 'r': return '\r'; + case 't': return '\t'; + case 'f': return '\f'; + case 'v': return '\v'; + case 'x': + var value = Convert.ToInt32(new string([(char)cps[i], (char)cps[i + 1]]), 16); + i += 2; + return value; + default: return c; + } + } + + /// Parses a class body after '['; leaves after the closing ']'. + private static List<(int Lo, int Hi)> ParseClass(int[] cps, ref int i) + { + var negated = false; + if (cps[i] == '^') + { + negated = true; + i++; + } + var set = new List<(int Lo, int Hi)>(); + while (cps[i] != ']') + { + int low; + if (cps[i] == '\\') + { + i++; + if (Shorthand(cps[i]) is { } shorthand) + { + set.AddRange(shorthand); + i++; + continue; + } + low = Escape(cps, ref i); + } + else + { + low = cps[i++]; + } + var high = low; + if (cps[i] == '-' && cps[i + 1] != ']') + { + i++; + if (cps[i] == '\\') + { + i++; + high = Escape(cps, ref i); + } + else + { + high = cps[i++]; + } + } + set.Add((low, high)); + } + i++; + return negated ? Complement(set) : Normalize(set); + } + + private static List<(int Lo, int Hi)> Normalize(IEnumerable<(int Lo, int Hi)> ranges) + { + var result = new List<(int Lo, int Hi)>(); + foreach (var (lo, hi) in ranges.OrderBy(r => r.Lo)) + { + if (result.Count > 0 && lo <= result[^1].Hi + 1) + result[^1] = (result[^1].Lo, Math.Max(result[^1].Hi, hi)); + else + result.Add((lo, hi)); + } + return result; + } + + private static List<(int Lo, int Hi)> Complement(IEnumerable<(int Lo, int Hi)> ranges) + { + var result = new List<(int Lo, int Hi)>(); + var next = 0; + foreach (var (lo, hi) in Normalize(ranges)) + { + if (lo > next) + result.Add((next, lo - 1)); + next = hi + 1; + } + if (next <= 0x10FFFF) + result.Add((next, 0x10FFFF)); + return result; + } + + /// Emits a code-point set as a single .NET atom over UTF-16 code units. + private static void Emit(StringBuilder builder, IEnumerable<(int Lo, int Hi)> ranges) + { + var bmp = new StringBuilder(); + var alternatives = new List(); + foreach (var (rangeLo, rangeHi) in Normalize(ranges)) + { + // Surrogate code points cannot be matched as scalar values; exclude them. + foreach (var (lo, hi) in new[] { (rangeLo, Math.Min(rangeHi, 0xD7FF)), (Math.Max(rangeLo, 0xE000), Math.Min(rangeHi, 0xFFFF)) }) + { + if (lo > hi) + continue; + bmp.Append(Unit(lo)); + if (hi > lo) + bmp.Append('-').Append(Unit(hi)); + } + if (rangeHi < 0x10000) + continue; + var start = Math.Max(rangeLo, 0x10000); + var (hsLo, lsLo) = Surrogates(start); + var (hsHi, lsHi) = Surrogates(rangeHi); + if (hsLo == hsHi) + { + alternatives.Add($"{Unit(hsLo)}[{Unit(lsLo)}-{Unit(lsHi)}]"); + continue; + } + // Partial leading/trailing high surrogates get their own alternative; full ones share one. + var fullLo = hsLo; + var fullHi = hsHi; + if (lsLo != 0xDC00) + alternatives.Add($"{Unit(fullLo++)}[{Unit(lsLo)}-\\uDFFF]"); + if (lsHi != 0xDFFF) + alternatives.Add($"{Unit(fullHi--)}[\\uDC00-{Unit(lsHi)}]"); + if (fullLo <= fullHi) + alternatives.Add($"[{Unit(fullLo)}-{Unit(fullHi)}][\\uDC00-\\uDFFF]"); + } + if (bmp.Length > 0) + alternatives.Insert(0, $"[{bmp}]"); + if (alternatives.Count == 0) + builder.Append(@"[^\u0000-\uFFFF]"); + else if (alternatives.Count == 1 && bmp.Length > 0) + builder.Append(alternatives[0]); + else + builder.Append("(?:").Append(string.Join('|', alternatives)).Append(')'); + } + + private static (int High, int Low) Surrogates(int codePoint) + { + var offset = codePoint - 0x10000; + return (0xD800 + (offset >> 10), 0xDC00 + (offset & 0x3FF)); + } + + private static string Unit(int value) => $"\\u{value:X4}"; + + private static int[] ToCodePoints(string text) + { + var points = new List(text.Length); + for (var i = 0; i < text.Length; i++) + { + if (char.IsHighSurrogate(text[i]) && i + 1 < text.Length && char.IsLowSurrogate(text[i + 1])) + { + points.Add(char.ConvertToUtf32(text[i], text[i + 1])); + i++; + } + else + { + points.Add(text[i]); + } + } + return points.ToArray(); + } +} + +/// Raised when a regular expression is outside the GTS profile or the engine fails on it. +internal sealed class GtsRegexException(string message) : Exception(message); diff --git a/Gts.Store/Validation/GtsRegexKeywords.cs b/Gts.Store/Validation/GtsRegexKeywords.cs index 3ba8861..d8bbd0a 100644 --- a/Gts.Store/Validation/GtsRegexKeywords.cs +++ b/Gts.Store/Validation/GtsRegexKeywords.cs @@ -16,7 +16,14 @@ private GtsPatternKeyword() { } if (value.ValueKind is not JsonValueKind.String) throw new JsonSchemaException($"'{Name}' value must be a string, found {value.ValueKind}"); - return new Regex(value.GetString()!, RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(250)); + try + { + return GtsRegexCompiler.Compile(value.GetString()!); + } + catch (GtsRegexException ex) + { + throw new JsonSchemaException(ex.Message); + } } } @@ -36,14 +43,54 @@ private GtsPatternPropertiesKeyword() { } { if (property.Value.ValueKind is not (JsonValueKind.Object or JsonValueKind.True or JsonValueKind.False)) throw new JsonSchemaException("Values must be valid schemas"); - regexes.Add(property.Name, new Regex(property.Name, RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(250))); + try + { + // The base PatternPropertiesKeyword looks the compiled value up by the + // JSON-pointer-escaped pattern (as it appears in the evaluation path), so the + // dictionary MUST be keyed that way. The regex itself compiles from the raw name. + regexes.Add(GtsJsonPointer.Escape(property.Name), GtsRegexCompiler.Compile(property.Name)); + } + catch (GtsRegexException ex) + { + throw new JsonSchemaException(ex.Message); + } } return regexes; } } +/// +/// The base keyword compiles sibling patternProperties names with .NET's ECMAScript engine, +/// which rejects in-profile expressions (e.g. astral ranges). Those regexes are unused: evaluation +/// reads the patternProperties annotation. So build without them. +/// +internal sealed class GtsAdditionalPropertiesKeyword : AdditionalPropertiesKeyword +{ + internal static GtsAdditionalPropertiesKeyword Handler { get; } = new(); + + private GtsAdditionalPropertiesKeyword() { } + + public override void BuildSubschemas(KeywordData keyword, BuildContext context) + { + if (context.LocalSchema.ValueKind is JsonValueKind.Object && context.LocalSchema.TryGetProperty("patternProperties", out _)) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + foreach (var property in context.LocalSchema.EnumerateObject()) + if (property.Name != "patternProperties") + property.WriteTo(writer); + writer.WriteEndObject(); + } + context = context with { LocalSchema = JsonDocument.Parse(stream.ToArray()).RootElement }; + } + base.BuildSubschemas(keyword, context); + } +} + internal static class GtsRegexDialect { internal static Dialect WithBoundedPatterns(Dialect dialect) => dialect - .With([GtsPatternKeyword.Handler, GtsPatternPropertiesKeyword.Handler]); + .With([GtsPatternKeyword.Handler, GtsPatternPropertiesKeyword.Handler, GtsAdditionalPropertiesKeyword.Handler]); } diff --git a/Gts.Store/Validation/GtsRegexProfile.cs b/Gts.Store/Validation/GtsRegexProfile.cs new file mode 100644 index 0000000..875356f --- /dev/null +++ b/Gts.Store/Validation/GtsRegexProfile.cs @@ -0,0 +1,449 @@ +using System.Text; + +namespace Gts.Store.Validation; + +/// +/// The GTS regular-expression profile (README §11.0.1, ADR-0006). +/// +/// Checks the shared ECMA-262 u/RE2 syntax subset and support bounds without executing +/// expressions. A dedicated parser is needed because engine parsers (including .NET's) accept +/// extra syntax such as nested classes, set operations, lookaround, and backreferences. +/// +/// This is a direct port of the Rust reference implementation (gts/src/regex_profile.rs). +/// +internal static class GtsRegexProfile +{ + /// Maximum length in code points after expanding counted repetitions. + internal const int MaxExpandedLen = 4096; + + /// Deepest supported nesting of groups. + internal const int MaxGroupNesting = 32; + + /// Largest supported repetition count, also as the product of nested counts. + internal const long MaxRepeat = 1000; + + private const string SyntaxChars = "^$\\.*+?()[]{}|"; + + /// Whether is supported, as format: "regex" asserts. + internal static bool IsSupported(string expression) => Check(expression) is null; + + /// + /// Builds the standard "unsupported regular expression" diagnostic shared by every path that + /// reports a profile violation, so the wording and truncation live in one place. + /// is an optional JSON-pointer-style schema location. + /// + internal static string Describe(string expression, string reason, string? location = null) + { + const int shown = 80; + var display = expression.Length <= shown ? expression : expression[..shown] + "..."; + var at = location is null ? "" : $" at '{location}'"; + return $"unsupported regular expression '{display}'{at}: {reason} " + + "(GTS regular-expression profile, README section 11.0.1)"; + } + + /// + /// Checks that is in the profile and within its bounds. + /// Returns null when supported, otherwise a human-readable reason why it is not. + /// + internal static string? Check(string expression) + { + try + { + return new Parser(expression).Run(); + } + catch (UnsupportedException ex) + { + return ex.Message; + } + } + + private sealed class UnsupportedException(string message) : Exception(message); + + /// What a parsed node contributes to the support bounds. + private readonly struct Size(long expanded, long product) + { + /// Expanded length in code points, saturating. + internal long Expanded { get; } = expanded; + + /// Largest product of counted-repetition factors along a nesting path. + internal long Product { get; } = product; + + internal static Size Spelled(long len) => new(len, 1); + + internal Size Then(Size next) => new( + SaturatingAdd(Expanded, next.Expanded), + Math.Max(Product, next.Product)); + } + + private static long SaturatingAdd(long a, long b) + { + var sum = unchecked(a + b); + if (((a ^ sum) & (b ^ sum)) < 0) + return long.MaxValue; + return sum; + } + + private static long SaturatingMul(long a, long b) + { + try + { + return checked(a * b); + } + catch (OverflowException) + { + return long.MaxValue; + } + } + + private sealed class Parser + { + private readonly int[] _chars; + private int _pos; + private int _depth; + + internal Parser(string expression) + { + // Reject overlong source early: expanded length is at least source length. + var codePoints = ToCodePoints(expression); + if (codePoints.Length > MaxExpandedLen) + throw new UnsupportedException($"it is longer than {MaxExpandedLen} code points"); + _chars = codePoints; + } + + internal string? Run() + { + var size = Disjunction(); + if (_pos < _chars.Length) + // Only an unmatched ')' stops a top-level disjunction early. + throw Error($"unmatched '{CharAt(_pos)}'"); + if (size.Expanded > MaxExpandedLen) + throw new UnsupportedException($"its expanded length exceeds {MaxExpandedLen} code points"); + return null; + } + + private int? Peek() => _pos < _chars.Length ? _chars[_pos] : null; + + private int? PeekAt(int offset) => _pos + offset < _chars.Length ? _chars[_pos + offset] : null; + + private bool Eat(char c) + { + if (Peek() == c) + { + _pos++; + return true; + } + return false; + } + + private UnsupportedException Error(string reason) => + new($"{reason} at offset {_pos}"); + + /// Stops at the end of input or before an unmatched ')'. + private Size Disjunction() + { + var size = Alternative(); + while (Eat('|')) + size = size.Then(Size.Spelled(1)).Then(Alternative()); + return size; + } + + private Size Alternative() + { + var size = Size.Spelled(0); + while (Peek() is { } c) + { + Size term; + if (c is '|' or ')') + break; + if (c is '^' or '$') + { + _pos++; + if (Peek() is { } next && IsQuantifierStart(next)) + throw Error($"quantified assertion '{(char)c}'"); + term = Size.Spelled(1); + } + else + { + var atom = Atom(); + term = Quantified(atom); + } + size = size.Then(term); + // Bail out early: lengths only grow. + if (size.Expanded > MaxExpandedLen) + throw new UnsupportedException($"its expanded length exceeds {MaxExpandedLen} code points"); + } + return size; + } + + private Size Atom() + { + if (Peek() is not { } c) + throw Error("unexpected end"); + switch (c) + { + case '(': + return Group(); + case '[': + return Class(); + case '\\': + var start = _pos; + _pos++; + AtomEscape(); + return Size.Spelled(_pos - start); + case '*' or '+' or '?' or '{': + throw Error($"quantifier '{(char)c}' without operand"); + case '}' or ']': + throw Error($"unescaped '{(char)c}'"); + default: + // '.' or a literal. + _pos++; + return Size.Spelled(1); + } + } + + private Size Group() + { + var start = _pos; + _pos++; + if (Eat('?') && !Eat(':')) + throw new UnsupportedException( + $"only capturing and '(?:' groups are supported, at offset {start}"); + var opening = _pos - start; + _depth++; + if (_depth > MaxGroupNesting) + throw Error($"groups nest deeper than {MaxGroupNesting}"); + var inner = Disjunction(); + if (!Eat(')')) + throw new UnsupportedException($"unclosed group opened at offset {start}"); + _depth--; + return Size.Spelled(opening).Then(inner).Then(Size.Spelled(1)); + } + + /// After '\' outside a class. + private void AtomEscape() + { + if (Peek() is not { } c) + throw Error("trailing backslash"); + if (c is 'd' or 'D' or 'w' or 'W' or 's' or 'S') + { + _pos++; + return; + } + CharEscape(false); + } + + /// After '\': an escape denoting one code point, which it returns. + private int CharEscape(bool inClass) + { + if (Peek() is not { } c) + throw Error("trailing backslash"); + _pos++; + switch (c) + { + case 'n': return '\n'; + case 'r': return '\r'; + case 't': return '\t'; + case 'f': return '\f'; + case 'v': return '\v'; + case 'x': + var high = HexDigit(PeekAt(0)); + var low = HexDigit(PeekAt(1)); + if (high is null || low is null) + throw Error("'\\x' takes exactly two hex digits"); + _pos += 2; + return high.Value * 16 + low.Value; + case '/': return '/'; + case '-' when inClass: return '-'; + default: + if (c <= char.MaxValue && SyntaxChars.Contains((char)c)) + return c; + throw Error($"unsupported escape '\\{(char)c}'"); + } + } + + private static int? HexDigit(int? c) + { + if (c is null) + return null; + var ch = c.Value; + if (ch is >= '0' and <= '9') + return ch - '0'; + if (ch is >= 'a' and <= 'f') + return ch - 'a' + 10; + if (ch is >= 'A' and <= 'F') + return ch - 'A' + 10; + return null; + } + + private Size Class() + { + var start = _pos; + _pos++; + Eat('^'); + if (Peek() == ']') + throw Error("a class cannot be empty or start with ']'"); + var first = _pos; + while (true) + { + if (Peek() is not { } c) + throw new UnsupportedException($"unclosed class opened at offset {start}"); + if (c == ']') + { + _pos++; + break; + } + if (c == '-' && _pos != first && PeekAt(1) != ']') + // A range operator after a shorthand class, or a second one. + throw Error("'-' must be the first or last item of a class"); + var low = ClassAtom(); + if (Peek() == '-' && PeekAt(1) is { } next && next != ']') + { + UnambiguousInClass(); + _pos++; + var hi = ClassAtom(); + if (low is null || hi is null) + throw Error("a shorthand class cannot bound a range"); + if (low.Value > hi.Value) + throw Error($"reversed range '{CodePoint(low.Value)}-{CodePoint(hi.Value)}'"); + } + } + return Size.Spelled(_pos - start); + } + + /// One class item; null for a shorthand class. + private int? ClassAtom() + { + if (Peek() is not { } c) + throw Error("unexpected end"); + switch (c) + { + case '[': + throw Error("unescaped '[' in a class"); + case '\\': + _pos++; + if (Peek() is { } e && e is 'd' or 'D' or 'w' or 'W' or 's' or 'S') + { + _pos++; + return null; + } + return CharEscape(true); + default: + UnambiguousInClass(); + _pos++; + return c; + } + } + + /// Reject '&&', '--' and '~~', which Rust reads as class set operations. + private void UnambiguousInClass() + { + if (Peek() is { } c && c is '&' or '-' or '~' && PeekAt(1) == c) + throw Error($"ambiguous '{(char)c}{(char)c}' in a class"); + } + + /// Applies an optional quantifier to an atom of the given size. + private Size Quantified(Size atom) + { + var start = _pos; + Size size; + switch (Peek()) + { + case '*' or '+' or '?': + _pos++; + Eat('?'); + size = Size.Spelled(_pos - start).Then(atom); + break; + case '{': + _pos++; + var min = Count(); + long factor; + long copies; + if (Eat('}')) + { + factor = min; + copies = Math.Max(min, 1); + } + else if (Eat(',')) + { + if (Eat('}')) + { + factor = min; + copies = min + 1; + } + else + { + var max = Count(); + if (!Eat('}')) + throw Error("malformed counted repetition"); + if (min > max) + throw Error($"reversed repetition {{{min},{max}}}"); + factor = max; + copies = Math.Max(max, 1); + } + } + else + { + throw Error("malformed counted repetition"); + } + Eat('?'); + var product = SaturatingMul(Math.Max(factor, 1), atom.Product); + if (product > MaxRepeat) + throw Error($"nested counted repetitions exceed {MaxRepeat} in total"); + size = new Size( + SaturatingAdd(_pos - start, SaturatingMul(copies, atom.Expanded)), + product); + break; + default: + return atom; + } + if (Peek() is { } trailing && IsQuantifierStart(trailing)) + throw Error("stacked or possessive quantifier"); + return size; + } + + /// A repetition count of at most . + private long Count() + { + var start = _pos; + long value = 0; + while (Peek() is { } c && c is >= '0' and <= '9') + { + value = SaturatingAdd(SaturatingMul(value, 10), c - '0'); + _pos++; + } + if (_pos == start) + throw Error("malformed counted repetition"); + // RE2 reads `{01}` as literal text, not a repetition. + if (_pos - start > 1 && _chars[start] == '0') + throw new UnsupportedException($"repetition count at offset {start} has a leading zero"); + if (value > MaxRepeat) + throw new UnsupportedException( + $"repetition count {value} at offset {start} exceeds {MaxRepeat}"); + return value; + } + + private int CharAt(int pos) => _chars[pos]; + } + + private static bool IsQuantifierStart(int c) => c is '*' or '+' or '?' or '{'; + + private static string CodePoint(int value) => char.ConvertFromUtf32(value); + + private static int[] ToCodePoints(string text) + { + var points = new List(text.Length); + for (var i = 0; i < text.Length; i++) + { + if (char.IsHighSurrogate(text[i]) && i + 1 < text.Length && char.IsLowSurrogate(text[i + 1])) + { + points.Add(char.ConvertToUtf32(text[i], text[i + 1])); + i++; + } + else + { + points.Add(text[i]); + } + } + return points.ToArray(); + } +} diff --git a/Gts.Store/Validation/GtsSchemaRegexValidator.cs b/Gts.Store/Validation/GtsSchemaRegexValidator.cs new file mode 100644 index 0000000..ab70f3a --- /dev/null +++ b/Gts.Store/Validation/GtsSchemaRegexValidator.cs @@ -0,0 +1,207 @@ +using System.Text.Json.Nodes; + +namespace Gts.Store.Validation; + +/// +/// Checks every regular expression in the schema positions of a Type Schema document against the +/// GTS profile (, README §11.0.1). Mirrors the Rust reference's +/// schema_regex: it visits active and inactive schema positions, the root +/// x-gts-traits-schema, and the initial targets of $ref (local JSON pointers and +/// gts:// references), so a pattern or patternProperties key reached only +/// through a reference is still checked. Literal data (const, enum, default, +/// examples, x-gts-traits) and unknown keywords are visited only through references. +/// +/// This complements JSON-Schema compilation, which resolves external $ref targets lazily and +/// would otherwise miss an unsupported expression that never needs to be evaluated. +/// +internal static class GtsSchemaRegexValidator +{ + /// + /// Returns the first unsupported expression's diagnostic, or null when every expression + /// in the reachable schema graph is in profile. resolves a + /// gts:// reference target to its document. + /// + internal static string? Check(JsonObject document, Func load) + { + var walk = new Walk(load); + return walk.Node(document, document, Draft(document), "#"); + } + + private enum DraftKind + { + Draft07, + Draft201909, + Draft202012, + } + + private static DraftKind Draft(JsonObject document) => + GtsTypeSchema.TryGetSupportedDialect(document, out var dialect, out _) + ? dialect switch + { + "2019-09" => DraftKind.Draft201909, + "2020-12" => DraftKind.Draft202012, + _ => DraftKind.Draft07, + } + : DraftKind.Draft07; + + private enum Holds + { + Schema, + Array, + Map, + SchemaOrArray, + Dependencies, + } + + /// + /// Which keywords hold subschemas, per draft. This intentionally re-encodes knowledge that + /// JsonSchema.Net also owns: the library resolves external $ref targets lazily (only when + /// an instance is evaluated), so it would miss an unsupported expression in a referenced type that + /// never needs to be evaluated. This walker therefore traverses the graph eagerly and must be kept + /// in sync with the dialects the library supports (Draft-07 / 2019-09 / 2020-12). + /// + private static Holds? HoldsKind(DraftKind draft, string keyword) + { + var legacy = draft == DraftKind.Draft07; + var modern = !legacy; + var since2020 = draft == DraftKind.Draft202012; + return keyword switch + { + "not" or "additionalProperties" or "propertyNames" or "contains" => Holds.Schema, + "if" or "then" or "else" => Holds.Schema, + "allOf" or "anyOf" or "oneOf" => Holds.Array, + "properties" or "patternProperties" => Holds.Map, + "items" when since2020 => Holds.Schema, + "items" => Holds.SchemaOrArray, + "additionalItems" when !since2020 => Holds.Schema, + "prefixItems" when since2020 => Holds.Array, + "definitions" when legacy => Holds.Map, + "dependencies" when legacy => Holds.Dependencies, + "$defs" or "dependentSchemas" when modern => Holds.Map, + "unevaluatedItems" or "unevaluatedProperties" or "contentSchema" when modern => Holds.Schema, + _ => null, + }; + } + + private sealed class Walk(Func load) + { + private readonly HashSet _visited = new(ReferenceEqualityComparer.Instance); + private int _depth; + + /// + /// Visits as a schema position whose local $refs resolve in + /// . Returns the first unsupported expression's diagnostic. + /// + internal string? Node(JsonNode? value, JsonObject root, DraftKind draft, string location) + { + if (_depth >= GtsConstants.MaxNestingDepth) + return null; + if (value is not JsonObject map) + return null; + if (!_visited.Add(map)) + return null; + _depth++; + try + { + return Scoped(map, root, draft, location); + } + finally + { + _depth--; + } + } + + private string? Scoped(JsonObject map, JsonObject root, DraftKind draft, string location) + { + // Draft-07 ignores siblings of `$ref`, but the reference target is always checked. + if (map.TryGetPropertyValue("$ref", out var refNode) && refNode is JsonValue rv && + rv.TryGetValue(out var reference)) + { + var error = Reference(reference, root, draft); + if (error is not null) + return error; + if (draft == DraftKind.Draft07) + return null; + } + + // The root document's `x-gts-traits-schema` is itself a schema position. + if (ReferenceEquals(map, root) && map["x-gts-traits-schema"] is JsonObject traits) + { + var error = Node(traits, root, draft, Join(location, "x-gts-traits-schema")); + if (error is not null) + return error; + } + + if (map["pattern"] is JsonValue pv && pv.TryGetValue(out var pattern)) + { + var reason = GtsRegexProfile.Check(pattern); + if (reason is not null) + return GtsRegexProfile.Describe(pattern, reason, Join(location, "pattern")); + } + if (map["patternProperties"] is JsonObject patterns) + { + foreach (var (expression, _) in patterns) + { + var reason = GtsRegexProfile.Check(expression); + if (reason is not null) + return GtsRegexProfile.Describe(expression, reason, Join(Join(location, "patternProperties"), expression)); + } + } + + foreach (var (keyword, child) in map) + { + if (HoldsKind(draft, keyword) is not { } holds) + continue; + var at = Join(location, keyword); + switch (holds, child) + { + case (Holds.Schema or Holds.SchemaOrArray, JsonObject): + if (Node(child, root, draft, at) is { } e1) + return e1; + break; + case (Holds.Array or Holds.SchemaOrArray, JsonArray items): + for (var i = 0; i < items.Count; i++) + if (Node(items[i], root, draft, Join(at, i.ToString())) is { } e2) + return e2; + break; + case (Holds.Map or Holds.Dependencies, JsonObject members): + foreach (var (name, member) in members) + if (Node(member, root, draft, Join(at, name)) is { } e3) + return e3; + break; + } + } + return null; + } + + /// Visits the initial target of a $ref (local pointer or gts://). + private string? Reference(string reference, JsonObject root, DraftKind draft) + { + if (reference.StartsWith('#')) + return GtsJsonPointer.TryEvaluate(root, reference, out var target) + ? Node(target, root, draft, reference) + : null; + + var basePart = reference.Split('#')[0]; + var fragment = reference.Length > basePart.Length ? reference[basePart.Length..] : null; + var id = GtsConstants.StripUriPrefix(basePart); + if (!GtsId.TryParse(id, out var parsed) || parsed is null) + return null; + var document = load(parsed); + if (document is null) + return null; + var targetDraft = Draft(document); + if (string.IsNullOrEmpty(fragment) || fragment == "#") + return Node(document, document, targetDraft, reference); + return GtsJsonPointer.TryEvaluate(document, fragment, out var inner) + ? Node(inner, document, targetDraft, reference) + : null; + } + } + + private static string Join(string location, string segment) + { + var escaped = GtsJsonPointer.Escape(segment); + return location.Contains('#') ? $"{location}/{escaped}" : $"{location}#/{escaped}"; + } +} diff --git a/Gts.Tests/Validation/InstanceValidationTests.cs b/Gts.Tests/Validation/InstanceValidationTests.cs index 33e3537..08490b6 100644 --- a/Gts.Tests/Validation/InstanceValidationTests.cs +++ b/Gts.Tests/Validation/InstanceValidationTests.cs @@ -357,9 +357,11 @@ public async Task Catastrophic_pattern_is_bounded_and_does_not_hang() stopwatch.Stop(); // The point is that validation *returns* (bounded), rather than pinning - // a CPU forever. The adversarial value fails the pattern, so Ok is false. + // a CPU forever. Under the GTS regex profile (v0.15) the expression is in + // profile and runs in linear time via a non-backtracking engine, so it no + // longer times out: the adversarial value simply fails the pattern. Assert.False(result.Ok); - Assert.Contains("regular expression match timed out", result.SchemaErrors ?? []); + Assert.Contains(result.SchemaErrors ?? [], error => error.Contains("pattern", StringComparison.Ordinal)); Assert.True( stopwatch.Elapsed < TimeSpan.FromSeconds(2), $"validation took {stopwatch.Elapsed}, expected a bounded match time"); diff --git a/Gts.Tests/Validation/JsonInfrastructureTests.cs b/Gts.Tests/Validation/JsonInfrastructureTests.cs index 0a86c75..ab3f1c7 100644 --- a/Gts.Tests/Validation/JsonInfrastructureTests.cs +++ b/Gts.Tests/Validation/JsonInfrastructureTests.cs @@ -1,5 +1,4 @@ using System.Text.Json.Nodes; -using System.Text.RegularExpressions; using Gts.Store.Validation; using Json.Schema; @@ -95,7 +94,11 @@ public void Schema_engine_bounds_patterns_in_nested_resources() var instance = new JsonObject { ["code"] = new string('a', 40_000) + "!" }; var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - Assert.Throws(() => GtsJsonSchemaEngine.Default.EvaluateInline(instance, schema)); + // Under the GTS regex profile (v0.15) '(a+)+$' is in profile and is matched in linear + // time by a non-backtracking engine, so evaluation returns quickly (no timeout). The + // adversarial value ends in '!', so the anchored pattern does not match. + var result = GtsJsonSchemaEngine.Default.EvaluateInline(instance, schema); + Assert.False(result.IsValid); Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(2)); } From 2395d740efde06cef47460cd877b3fa3e73d4a1e Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sat, 24 Oct 2026 15:46:10 +0300 Subject: [PATCH 3/4] feat(refs): replace x-gts-ref with type, instance and pattern reference keywords Implement x-gts-type-ref, x-gts-instance-ref and x-gts-pattern-ref (README section 9.6) and reject the removed x-gts-ref. Derivation and evolution checks include the reference dimension via semantic scope inclusion, and derived properties composed with allOf are flattened before comparison. Bump the gts-spec pin to v0.17.0. Harden reference resolution: - resolve local refs of an inlined document against that document; - cap inlining at 100,000 nodes so shared refs cannot grow exponentially; - leave literal data (const, enum, default, examples) untouched; - evaluate the structural part of a reference-bearing not; - key the recursive-ref guard by instance path so every level is checked; - accept unsigned 32-bit versions in /$id. Signed-off-by: Artifizer --- .gts-spec-version | 2 +- Gts.Application/GtsOperationEndpoints.cs | 41 +- Gts.Store/GtsCastService.cs | 10 +- Gts.Store/GtsInstanceValidationService.cs | 12 +- Gts.Store/GtsRefConstraint.cs | 33 -- Gts.Store/GtsRefScopeCompat.cs | 452 ++++++++++++++++ Gts.Store/GtsRefValidator.cs | 488 +++++++++++++----- Gts.Store/GtsSchemaDependencyGraph.cs | 91 +++- Gts.Store/GtsSchemaDerivationValidator.cs | 26 +- Gts.Store/GtsSchemaKeywordValidator.cs | 8 +- Gts.Store/GtsSchemaKeywords.cs | 15 +- .../Validation/GtsSchemaDocumentNormalizer.cs | 25 +- .../ReferenceResolutionHardeningTests.cs | 98 ++++ 13 files changed, 1120 insertions(+), 181 deletions(-) delete mode 100644 Gts.Store/GtsRefConstraint.cs create mode 100644 Gts.Store/GtsRefScopeCompat.cs create mode 100644 Gts.Tests/Validation/ReferenceResolutionHardeningTests.cs diff --git a/.gts-spec-version b/.gts-spec-version index 574be60..6b60281 100644 --- a/.gts-spec-version +++ b/.gts-spec-version @@ -1 +1 @@ -v0.14.5 +v0.17.0 diff --git a/Gts.Application/GtsOperationEndpoints.cs b/Gts.Application/GtsOperationEndpoints.cs index 855ac93..0729007 100644 --- a/Gts.Application/GtsOperationEndpoints.cs +++ b/Gts.Application/GtsOperationEndpoints.cs @@ -39,24 +39,49 @@ private static async Task Compatibility(GtsRegistry registry, string ol var registered = await registry.GetAllAsync().ConfigureAwait(false); var schemas = registered.Where(entity => entity.IsSchema && entity.GtsId is not null) .ToDictionary(entity => entity.GtsId!.Id, entity => entity.Content, StringComparer.Ordinal); - var oldFlat = GtsJsonSchemaEvolutionCompatibility.FlattenSchema(GtsHttpApiExtensions.ResolveSchemaRefs(oldEntity.Content, schemas, new HashSet())); - var newFlat = GtsJsonSchemaEvolutionCompatibility.FlattenSchema(GtsHttpApiExtensions.ResolveSchemaRefs(newEntity.Content, schemas, new HashSet())); + var oldResolved = GtsHttpApiExtensions.ResolveSchemaRefs(oldEntity.Content, schemas, new HashSet()); + var newResolved = GtsHttpApiExtensions.ResolveSchemaRefs(newEntity.Content, schemas, new HashSet()); + var oldFlat = GtsJsonSchemaEvolutionCompatibility.FlattenSchema(oldResolved); + var newFlat = GtsJsonSchemaEvolutionCompatibility.FlattenSchema(newResolved); var (backward, backwardErrors, forward, forwardErrors) = GtsSchemaCompatibilityService.CompareEvolution(oldFlat, newFlat); + + // Fold in the GTS reference dimension (§9.6.1): backward needs Valid(old) ⊆ Valid(new) and + // forward needs Valid(new) ⊆ Valid(old). An unprovable inclusion is reported as "unknown". + var refBackward = GtsRefScopeCompat.SchemaRefRelation(newResolved, oldResolved, null, null); + var refForward = GtsRefScopeCompat.SchemaRefRelation(oldResolved, newResolved, null, null); + var backwardStatus = CombineStatus(backward, refBackward); + var forwardStatus = CombineStatus(forward, refForward); + var fullStatus = CombineFull(backwardStatus, forwardStatus); return Results.Json(new { old = oldTypeId, @new = newTypeId, - backward_compatibility = backward ? "compatible" : "incompatible", - forward_compatibility = forward ? "compatible" : "incompatible", - full_compatibility = backward && forward ? "compatible" : "incompatible", - is_backward_compatible = backward, - is_forward_compatible = forward, - is_fully_compatible = backward && forward, + backward_compatibility = backwardStatus, + forward_compatibility = forwardStatus, + full_compatibility = fullStatus, + is_backward_compatible = backwardStatus == "compatible", + is_forward_compatible = forwardStatus == "compatible", + is_fully_compatible = fullStatus == "compatible", backward_errors = backwardErrors, forward_errors = forwardErrors }); } + /// Combines a structural verdict with the three-valued reference relation (§9.6.1). + private static string CombineStatus(bool structurallyCompatible, GtsRefScopeCompat.Relation reference) + { + if (!structurallyCompatible || reference == GtsRefScopeCompat.Relation.Excluded) + return "incompatible"; + return reference == GtsRefScopeCompat.Relation.Unknown ? "unknown" : "compatible"; + } + + private static string CombineFull(string backward, string forward) + { + if (backward == "incompatible" || forward == "incompatible") + return "incompatible"; + return backward == "unknown" || forward == "unknown" ? "unknown" : "compatible"; + } + private static async Task Cast(HttpRequest request, GtsRegistry registry) { var body = await JsonSerializer.DeserializeAsync(request.Body, GtsHttpJson.Options).ConfigureAwait(false); diff --git a/Gts.Store/GtsCastService.cs b/Gts.Store/GtsCastService.cs index b8818dc..5856b77 100644 --- a/Gts.Store/GtsCastService.cs +++ b/Gts.Store/GtsCastService.cs @@ -48,7 +48,15 @@ internal async ValueTask CastAsync(string instanceId, Gts ? CanonicalSchema(stored.Content) : null; } - var targetEffective = GtsJsonSchemaEvolutionCompatibility.FlattenSchema(GtsSchemaDependencyGraph.Resolve(targetDocument, Load)); + JsonObject targetEffective; + try + { + targetEffective = GtsJsonSchemaEvolutionCompatibility.FlattenSchema(GtsSchemaDependencyGraph.Resolve(targetDocument, Load)); + } + catch (GtsSchemaTooLargeException) + { + return new GtsInstanceCastResult { Ok = false, InstanceId = id, FromSchemaId = sourceSchemaId, ToSchemaId = targetSchemaId, FailureReason = GtsValidationFailure.SchemaNormalizationFailed, Comparison = comparison }; + } var casted = GtsInstanceCast.CastToEffectiveSchema(entity.Content, targetEffective); var schemas = entities.Where(candidate => candidate.IsSchema && candidate.GtsId is not null) .ToDictionary(candidate => candidate.GtsId!, candidate => GtsSchemaDocumentNormalizer.ForJsonSchemaEvaluation(candidate.Content)); diff --git a/Gts.Store/GtsInstanceValidationService.cs b/Gts.Store/GtsInstanceValidationService.cs index a4d3886..5b7f1a6 100644 --- a/Gts.Store/GtsInstanceValidationService.cs +++ b/Gts.Store/GtsInstanceValidationService.cs @@ -73,9 +73,9 @@ internal async ValueTask ValidateAsync( ? GtsSchemaDocumentNormalizer.CanonicalizeKeywords(loaded) : loaded; } - var effectiveSchema = GtsSchemaDependencyGraph.Resolve(schemaDocument, Load); try { + var effectiveSchema = GtsSchemaDependencyGraph.Resolve(schemaDocument, Load); var evaluation = GtsJsonSchemaEvaluator.Evaluate(content, schemaId, schemas); var referenceErrors = GtsRefValidator.ValidateInstance(content, effectiveSchema, schemaId.Id, entities, refValidationMode); if (evaluation.IsValid && referenceErrors.Count == 0) @@ -88,6 +88,16 @@ internal async ValueTask ValidateAsync( SchemaErrors = referenceErrors.Count > 0 ? referenceErrors : GtsJsonSchemaEvaluator.FlattenErrors(evaluation) }; } + catch (GtsSchemaTooLargeException exception) + { + return new GtsInstanceValidationResult + { + Ok = false, + Id = instanceId, + FailureReason = GtsValidationFailure.SchemaValidationFailed, + SchemaErrors = new[] { exception.Message } + }; + } catch (RegexMatchTimeoutException) { // An untrusted schema `pattern` whose match exceeds the engine's diff --git a/Gts.Store/GtsRefConstraint.cs b/Gts.Store/GtsRefConstraint.cs deleted file mode 100644 index 8e91a45..0000000 --- a/Gts.Store/GtsRefConstraint.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Gts.Store; - -internal readonly record struct GtsRefConstraint(string Pattern) -{ - internal static bool TryCreate(string operand, string selectedTypeId, out GtsRefConstraint constraint) - { - var pattern = operand == "/$id" ? selectedTypeId : GtsConstants.StripUriPrefix(operand); - if (operand.StartsWith('/') && operand != "/$id" || !pattern.StartsWith(GtsConstants.IdPrefix, StringComparison.Ordinal)) - { - constraint = default; - return false; - } - var valid = pattern.Contains('*') - ? pattern.Count(character => character == '*') == 1 && pattern.EndsWith('*') - : GtsId.TryParse(pattern, out _); - constraint = new GtsRefConstraint(pattern); - return valid; - } - - internal bool Matches(string value) - { - if (Pattern == GtsConstants.IdPrefix + "*") return true; - if (Pattern.EndsWith('*')) - return value.StartsWith(Pattern[..^1], StringComparison.Ordinal); - if (!value.StartsWith(Pattern, StringComparison.Ordinal)) - return false; - // Prefix matching alone ignores segment boundaries: an exact constraint such as - // "gts.a.b.c.d.v1~x.y.z.w.v1" would otherwise also accept "…w.v12" or "…w.v1.5". - // Type patterns (ending with '~') admit derived identifiers; any other (exact) pattern - // requires a full match or a '~' segment boundary immediately after the pattern. - return value.Length == Pattern.Length || Pattern.EndsWith('~') || value[Pattern.Length] == '~'; - } -} diff --git a/Gts.Store/GtsRefScopeCompat.cs b/Gts.Store/GtsRefScopeCompat.cs new file mode 100644 index 0000000..f314327 --- /dev/null +++ b/Gts.Store/GtsRefScopeCompat.cs @@ -0,0 +1,452 @@ +using System.Text.Json.Nodes; + +namespace Gts.Store; + +/// +/// Derivation/evolution comparison of the GTS reference keywords (README §9.6.1 "Derivation and +/// evolution", ADR-0007/0008). A property constraining a GTS reference accepts a set of strings; a +/// derived property is admissible when the strings it accepts are a subset of the base's. The two +/// entity-reference keywords compare kind-filtered §10 scopes; x-gts-pattern-ref compares +/// selection-set coverage. Composition (allOf, anyOf, not) combines scopes by +/// intersection, union and complement. +/// +/// The comparison is a sound three-valued decision: or +/// only when provable, otherwise . +/// OP#12 admission fails closed (unknown rejected); OP#8 reports unknown. Ported from the Go/Python +/// reference (ref_scope_compat). +/// +public static class GtsRefScopeCompat +{ + public enum Relation + { + Included, + Excluded, + Unknown, + } + + private const int MaxDepth = 64; + private const int MinInstanceSegmentLen = 10; // "a.a.a.a.v0" + private const string SelfIdRef = "/$id"; + + private static readonly string[] LiteralKeywords = ["const", "enum", "default", "examples"]; + + /// + /// Three-valued reference-dimension relation Valid(derived) ⊆ Valid(base) over two + /// resolved schemas, resolving each schema's /$id to its own type. + /// + public static Relation SchemaRefRelation(JsonNode? baseSchema, JsonNode? derived, string? baseSelfId, string? derivedSelfId) + { + // Only the base's reference keywords restrict: a derived-only constraint can only narrow, + // and a keyword-free base imposes no restriction (README §9.6.1). The scan is far cheaper + // than the deep copy and scope walk, so bail out early for the common keyword-free base. + if (!MentionsRefKeyword(baseSchema, wantSelfId: false)) + return Relation.Included; + var baseEval = WithResolvedSelf(baseSchema, baseSelfId ?? SelfIdOf(baseSchema)); + var derivedEval = WithResolvedSelf(derived, derivedSelfId ?? SelfIdOf(derived)); + return SchemaRelation(baseEval, derivedEval, 0); + } + + private enum Kind { Top, Atom, Union, Inter, Not, Undecidable } + + private sealed class Scope(Kind kind) + { + public Kind Kind { get; } = kind; + public string? RefKeyword { get; set; } + public GtsPattern? Pattern { get; set; } + public bool Empty { get; set; } + public List Children { get; set; } = []; + public Scope? Child { get; set; } + } + + private static bool IsPatternRef(string keyword) => keyword == GtsSchemaKeywords.PatternRef; + + private static Scope? RefAtom(string keyword, JsonNode? operand) + { + if (operand is not JsonValue value || !value.TryGetValue(out var spelling) || spelling == SelfIdRef) + return null; + if (!GtsPattern.TryParse(spelling, out var pattern)) + return null; + var atom = new Scope(Kind.Atom) { RefKeyword = keyword, Pattern = pattern }; + // An instance reference to a concrete Type ID too long for any instance tail selects nothing. + if (keyword == GtsSchemaKeywords.InstanceRef && !pattern!.HasWildcard && spelling.EndsWith('~') + && spelling.Length + MinInstanceSegmentLen > GtsPattern.MaxLength) + atom.Empty = true; + return atom; + } + + private static (Scope Scope, bool Has) BuildRefScope(JsonNode? prop, int depth) + { + if (prop is not JsonObject obj) + return (new Scope(Kind.Top), false); + if (depth >= MaxDepth) + return (new Scope(Kind.Undecidable), true); + + var parts = new List(); + var has = false; + + foreach (var keyword in GtsSchemaKeywords.ReferenceKeywords) + { + if (!obj.TryGetPropertyValue(keyword, out var operand)) + continue; + has = true; + var atom = RefAtom(keyword, operand); + if (atom is not null) + parts.Add(atom); + } + + if (obj["not"] is JsonObject notValue) + { + var (childScope, childHas) = BuildRefScope(notValue, depth + 1); + if (childHas) + { + has = true; + parts.Add(new Scope(Kind.Not) { Child = childScope }); + } + } + + foreach (var key in new[] { "anyOf", "oneOf" }) + { + if (obj[key] is not JsonArray branches) + continue; + var union = new List(); + var branchHas = false; + foreach (var branch in branches) + { + if (branch is not JsonObject) + { + union.Add(new Scope(Kind.Top)); + continue; + } + var (bs, bh) = BuildRefScope(branch, depth + 1); + if (bh) + branchHas = true; + union.Add(bs); + } + if (branchHas) + { + has = true; + parts.Add(new Scope(Kind.Union) { Children = union }); + } + } + + if (obj["allOf"] is JsonArray allOf) + { + foreach (var branch in allOf) + { + if (branch is not JsonObject) + continue; + var (bs, bh) = BuildRefScope(branch, depth + 1); + if (bh) + { + has = true; + parts.Add(bs); + } + } + } + + if (parts.Count == 0) + return (new Scope(Kind.Top), has); + if (parts.Count == 1) + return (parts[0], has); + return (new Scope(Kind.Inter) { Children = parts }, has); + } + + private static Relation RelationOf(Scope baseScope, Scope derived) + { + if (baseScope.Kind == Kind.Undecidable || derived.Kind == Kind.Undecidable) + return Relation.Unknown; + if (baseScope.Kind == Kind.Top) + return Relation.Included; + if (derived.Kind == Kind.Top) + return Relation.Excluded; + + switch (derived.Kind) + { + case Kind.Union: + var unionResult = Relation.Included; + foreach (var child in derived.Children) + { + var r = RelationOf(baseScope, child); + if (r == Relation.Excluded) + return Relation.Excluded; + if (r == Relation.Unknown) + unionResult = Relation.Unknown; + } + return unionResult; + case Kind.Inter: + foreach (var child in derived.Children) + if (RelationOf(baseScope, child) == Relation.Included) + return Relation.Included; + return Relation.Unknown; + case Kind.Not: + // complement(D) ⊆ complement(B) ⟺ B ⊆ D. + if (baseScope.Kind == Kind.Not) + return RelationOf(derived.Child!, baseScope.Child!); + return Relation.Unknown; + } + + // Derived is an atom; dispatch on the base side. + switch (baseScope.Kind) + { + case Kind.Union: + foreach (var child in baseScope.Children) + if (RelationOf(child, derived) == Relation.Included) + return Relation.Included; + return Relation.Unknown; + case Kind.Inter: + var interResult = Relation.Included; + foreach (var child in baseScope.Children) + { + var r = RelationOf(child, derived); + if (r == Relation.Excluded) + return Relation.Excluded; + if (r == Relation.Unknown) + interResult = Relation.Unknown; + } + return interResult; + case Kind.Atom: + return AtomRelation(baseScope, derived); + default: + return Relation.Unknown; + } + } + + private static Relation AtomRelation(Scope baseScope, Scope derived) + { + if (derived.Empty) + return Relation.Included; + if (IsPatternRef(baseScope.RefKeyword!) != IsPatternRef(derived.RefKeyword!)) + return Relation.Unknown; + if (baseScope.RefKeyword != derived.RefKeyword) + return Relation.Excluded; // type vs instance: disjoint nonempty scopes + return baseScope.Pattern!.Covers(derived.Pattern!) ? Relation.Included : Relation.Excluded; + } + + private static Relation RefScopeRelation(JsonNode? baseProp, JsonNode? derivedProp) + { + var (baseScope, baseHas) = BuildRefScope(baseProp, 0); + if (!baseHas) + return Relation.Included; // base imposes no reference restriction + var (derivedScope, derivedHas) = BuildRefScope(derivedProp, 0); + if (derivedHas) + return RelationOf(baseScope, derivedScope); + var (values, enumerates) = CollectEnumeratedValues(derivedProp); + if (!enumerates) + return Relation.Excluded; // a plain string/regex accepts values outside the base scope + foreach (var value in values) + { + if (value is not JsonValue jv || !jv.TryGetValue(out var text) || !ScopeAccepts(baseScope, text)) + return Relation.Excluded; + } + return Relation.Included; + } + + private static (List Values, bool Enumerates) CollectEnumeratedValues(JsonNode? prop) + { + if (prop is not JsonObject obj) + return ([], false); + if (obj.TryGetPropertyValue("const", out var constValue)) + return ([constValue], true); + if (obj["enum"] is JsonArray enumArray) + return ([.. enumArray], true); + return ([], false); + } + + private static bool ScopeAccepts(Scope scope, string value) + { + switch (scope.Kind) + { + case Kind.Top: + return true; + case Kind.Not: + return !ScopeAccepts(scope.Child!, value); + case Kind.Union: + return scope.Children.Any(c => ScopeAccepts(c, value)); + case Kind.Inter: + return scope.Children.All(c => ScopeAccepts(c, value)); + case Kind.Atom: + if (scope.Empty) + return false; + return AtomAccepts(scope.RefKeyword!, scope.Pattern!, value); + default: + return false; + } + } + + /// Whether a single reference atom accepts the candidate string (kind + match / coverage). + private static bool AtomAccepts(string keyword, GtsPattern operandPattern, string value) + { + if (IsPatternRef(keyword)) + return GtsPattern.TryParse(value, out var candidatePattern) && operandPattern.Covers(candidatePattern!); + if (!GtsPattern.TryParse(value, out var concrete) || concrete!.HasWildcard) + return false; + var requireType = keyword == GtsSchemaKeywords.TypeRef; + if (value.EndsWith('~') != requireType) + return false; + return operandPattern.MatchesCandidate(value); + } + + // --- effective-property folding (allOf) ------------------------------------------------ + + private static (JsonNode? Operand, bool Ok) NarrowerRefOperand(string keyword, JsonNode? a, JsonNode? b) + { + var atomA = RefAtom(keyword, a); + var atomB = RefAtom(keyword, b); + if (atomA is null || atomB is null) + return (null, false); + if (atomA.Pattern!.Covers(atomB.Pattern!)) + return (b, true); // b ⊆ a + if (atomB.Pattern!.Covers(atomA.Pattern!)) + return (a, true); // a ⊆ b + return (null, false); + } + + private static JsonNode? MergeEffectiveProperty(JsonNode? prev, JsonNode? next) + { + if (prev is not JsonObject prevObj || next is not JsonObject nextObj) + return next; + var merged = (JsonObject)nextObj.DeepClone(); + foreach (var keyword in GtsSchemaKeywords.ReferenceKeywords) + { + if (!prevObj.TryGetPropertyValue(keyword, out var prevOperand)) + continue; + if (!merged.ContainsKey(keyword)) + { + merged[keyword] = prevOperand?.DeepClone(); + continue; + } + var (narrower, ok) = NarrowerRefOperand(keyword, prevOperand, merged[keyword]); + if (ok) + { + merged[keyword] = narrower?.DeepClone(); + continue; + } + // Incomparable operands: keep both as a conjunction. + var nextOperand = merged[keyword]; + merged.Remove(keyword); + var both = merged["allOf"] is JsonArray existing ? (JsonArray)existing.DeepClone() : new JsonArray(); + both.Add(new JsonObject { [keyword] = prevOperand?.DeepClone() }); + both.Add(new JsonObject { [keyword] = nextOperand?.DeepClone() }); + merged["allOf"] = both; + } + return merged; + } + + /// + /// The effective child subschemas a schema contributes, keyed by a stable position so the base + /// and derived sides line up: object properties, array items (single or tuple) and + /// prefixItems, and additionalProperties. Reference keywords apply at every such + /// location (§9.6.3), so the derivation/evolution comparison must recurse into all of them, not + /// only properties. allOf branches are folded in with reference-aware merging. + /// + private static Dictionary EffectiveChildren(JsonNode? schema, int depth) + { + var result = new Dictionary(StringComparer.Ordinal); + if (schema is not JsonObject obj || depth >= MaxDepth) + return result; + + void Add(string key, JsonNode? value) => result[key] = MergeEffectiveProperty(result.GetValueOrDefault(key), value); + + if (obj["allOf"] is JsonArray allOf) + foreach (var branch in allOf) + foreach (var (key, value) in EffectiveChildren(branch, depth + 1)) + Add(key, value); + if (obj["properties"] is JsonObject properties) + foreach (var (name, prop) in properties) + Add("p:" + name, prop); + switch (obj["items"]) + { + case JsonObject itemsSchema: + Add("items", itemsSchema); + break; + case JsonArray tupleItems: + for (var i = 0; i < tupleItems.Count; i++) + Add($"i:{i}", tupleItems[i]); + break; + } + if (obj["prefixItems"] is JsonArray prefixItems) + for (var i = 0; i < prefixItems.Count; i++) + Add($"pi:{i}", prefixItems[i]); + if (obj["additionalProperties"] is JsonObject additionalProperties) + Add("additionalProperties", additionalProperties); + if (obj["additionalItems"] is JsonObject additionalItems) + Add("additionalItems", additionalItems); + return result; + } + + // --- self-reference resolution --------------------------------------------------------- + + private static void ResolveSelfRefOperands(JsonNode? node, string selfId, int depth) + { + if (depth >= MaxDepth) + return; + if (node is JsonObject obj) + { + foreach (var keyword in GtsSchemaKeywords.ReferenceKeywords) + if (obj[keyword] is JsonValue v && v.TryGetValue(out var s) && s == SelfIdRef) + obj[keyword] = selfId; + foreach (var value in obj.Select(property => property.Value)) + ResolveSelfRefOperands(value, selfId, depth + 1); + } + else if (node is JsonArray array) + { + foreach (var value in array) + ResolveSelfRefOperands(value, selfId, depth + 1); + } + } + + private static string? SelfIdOf(JsonNode? schema) => + schema is JsonObject obj && obj["$id"] is JsonValue v && v.TryGetValue(out var id) + ? GtsConstants.StripUriPrefix(id) + : null; + + private static Relation Combine(Relation current, Relation child) + { + if (child == Relation.Excluded) + return Relation.Excluded; + if (child == Relation.Unknown && current != Relation.Excluded) + return Relation.Unknown; + return current; + } + + private static Relation SchemaRelation(JsonNode? baseSchema, JsonNode? derived, int depth) + { + if (depth >= MaxDepth) + return Relation.Unknown; + var result = RefScopeRelation(baseSchema, derived); + if (result == Relation.Excluded) + return Relation.Excluded; + var baseProps = EffectiveChildren(baseSchema, 0); + var derivedProps = EffectiveChildren(derived, 0); + foreach (var name in baseProps.Keys.Union(derivedProps.Keys)) + { + var child = SchemaRelation(baseProps.GetValueOrDefault(name), derivedProps.GetValueOrDefault(name), depth + 1); + result = Combine(result, child); + if (result == Relation.Excluded) + return Relation.Excluded; + } + return result; + } + + private static bool MentionsRefKeyword(JsonNode? node, bool wantSelfId) + { + if (node is JsonObject obj) + { + foreach (var keyword in GtsSchemaKeywords.ReferenceKeywords) + if (obj.TryGetPropertyValue(keyword, out var operand) && + (!wantSelfId || (operand is JsonValue v && v.TryGetValue(out var s) && s == SelfIdRef))) + return true; + return obj.Any(property => !LiteralKeywords.Contains(property.Key) && MentionsRefKeyword(property.Value, wantSelfId)); + } + return node is JsonArray array && array.Any(item => MentionsRefKeyword(item, wantSelfId)); + } + + private static JsonNode? WithResolvedSelf(JsonNode? schema, string? selfId) + { + if (selfId is null || !MentionsRefKeyword(schema, wantSelfId: true)) + return schema; + var copy = schema!.DeepClone(); + ResolveSelfRefOperands(copy, selfId, 0); + return copy; + } +} diff --git a/Gts.Store/GtsRefValidator.cs b/Gts.Store/GtsRefValidator.cs index de2f820..e466c11 100644 --- a/Gts.Store/GtsRefValidator.cs +++ b/Gts.Store/GtsRefValidator.cs @@ -4,8 +4,34 @@ namespace Gts.Store; +/// +/// Validates the three GTS identifier-reference keywords (README §9.6): x-gts-type-ref and +/// x-gts-instance-ref (entity references, §9.6.1) and x-gts-pattern-ref (pattern-valued +/// fields, §9.6.2). Operand validity (schema errors) is checked by walking schema positions; +/// candidate/value validity (ordinary validation failures, subject to the dialect's applicators) is +/// checked by walking the instance against the schema. +/// +/// Matching and coverage come from the §10 engine (). Entity-reference +/// candidates additionally require the right kind (Type IDs end in ~; Instance IDs do not) and +/// the selected registry policy; pattern references are registry-independent (§9.6.2). +/// public static class GtsRefValidator { + private enum RefKind + { + Type, + Instance, + Pattern, + } + + private static RefKind? KindOf(string keyword) => keyword switch + { + GtsSchemaKeywords.TypeRef => RefKind.Type, + GtsSchemaKeywords.InstanceRef => RefKind.Instance, + GtsSchemaKeywords.PatternRef => RefKind.Pattern, + _ => null, + }; + public static IReadOnlyList ValidatePatterns(JsonObject schema, string selectedTypeId) => ValidateSchema(schema, selectedTypeId, Array.Empty(), GtsRefValidationMode.None); @@ -19,7 +45,7 @@ internal static IReadOnlyList ValidateSchema( GtsRefValidationMode mode) { var errors = new List(); - WalkSchema(schema, schema, selectedTypeId, RefContext.Create(entities), mode, errors, 0); + WalkSchema(schema, selectedTypeId, RefContext.Create(entities), mode, errors, 0); return errors; } @@ -36,59 +62,135 @@ internal static IReadOnlyList ValidateInstance( return errors; } + /// The selected leaf Type ID that /$id resolves to, stripped of gts://. + private static string SelfId(string selectedTypeId) => GtsConstants.StripUriPrefix(selectedTypeId); + + /// The resolved operand of a reference keyword, or why it is a schema error. + private readonly record struct Operand(RefKind Kind, GtsPattern Pattern, bool IsWildcard, string? ConcreteId, bool IsSelfId); + + /// + /// Resolves a keyword operand to a §10 pattern, enforcing the operand contract (§9.6.1/§9.6.2). + /// Returns false and sets for a schema error. + /// + private static bool TryResolveOperand(RefKind kind, JsonNode? value, string selectedTypeId, out Operand operand, out string error) + { + operand = default; + error = ""; + var keyword = KeywordName(kind); + if (value is not JsonValue jsonValue || !jsonValue.TryGetValue(out var spelling)) + { + error = $"{keyword} operand must be a string"; + return false; + } + + if (spelling == "/$id") + { + var selected = SelfId(selectedTypeId); + // Kind is read from the spelling, not the int-based GtsId parser, so unsigned 32-bit + // versions are accepted. + if (string.IsNullOrEmpty(selected) || !GtsPattern.TryParse(selected, out var selfPattern) || + selfPattern!.HasWildcard || !selected.EndsWith('~')) + { + error = $"{keyword} operand '/$id' resolves to '{selected}', which is not a concrete GTS Type Identifier"; + return false; + } + operand = new Operand(kind, selfPattern, false, selected, IsSelfId: true); + return true; + } + + if (!GtsPattern.TryParse(spelling, out var pattern)) + { + error = $"{keyword} operand '{spelling}' is not a GTS identifier, a wildcard pattern or '/$id'"; + return false; + } + + // A concrete Instance ID selects no Type IDs: it is a schema error under x-gts-type-ref. + // Kind is determined from the parsed form (ends in `~`), not the int-based GtsId parser, + // so that unsigned 32-bit versions are handled. + if (kind == RefKind.Type && !pattern!.HasWildcard && !spelling.EndsWith('~')) + { + error = $"{keyword} operand '{spelling}' is a GTS Instance Identifier, which selects no GTS Type Identifiers"; + return false; + } + + operand = new Operand(kind, pattern!, pattern!.HasWildcard, pattern.HasWildcard ? null : spelling, IsSelfId: false); + return true; + } + + private static string KeywordName(RefKind kind) => kind switch + { + RefKind.Type => GtsSchemaKeywords.TypeRef, + RefKind.Instance => GtsSchemaKeywords.InstanceRef, + _ => GtsSchemaKeywords.PatternRef, + }; + + // ---- schema walk: operand validity + registry operand checks -------------------------- + private static void WalkSchema( JsonNode? node, - JsonObject root, string selectedTypeId, RefContext context, GtsRefValidationMode mode, List errors, int depth) { - if (depth >= GtsConstants.MaxNestingDepth) + if (depth >= GtsConstants.MaxNestingDepth || node is not JsonObject obj) return; - if (node is JsonObject obj) + + foreach (var keyword in GtsSchemaKeywords.ReferenceKeywords) { - if (obj.TryGetPropertyValue(GtsSchemaKeywords.Ref, out var refNode)) - { - if (refNode is not JsonValue value || !value.TryGetValue(out var pattern)) - errors.Add("x-gts-ref value must be a string"); - else if (!TryResolvePattern(pattern, selectedTypeId, out var resolved)) - errors.Add(pattern.StartsWith(GtsConstants.IdPrefix, StringComparison.Ordinal) && !pattern.Contains('*') - ? $"Invalid GTS identifier: {pattern}" - : $"Invalid x-gts-ref value: '{pattern}'"); - else if (mode != GtsRefValidationMode.None && resolved != GtsConstants.IdPrefix + "*") - { - var matches = context.Entities.Where(entity => Matches(entity.GtsId!.Id, resolved)).ToList(); - if (matches.Count == 0) - errors.Add(resolved.Contains('*') - ? $"x-gts-ref wildcard constraint '{resolved}' has no registered match" - : $"x-gts-ref constraint type '{resolved}' is not registered"); - else if (mode == GtsRefValidationMode.AnyValid && matches.All(entity => !context.ReferencedEntityValid(entity))) - errors.Add($"x-gts-ref constraint '{resolved}' has no valid registered match"); - } - } - foreach (var keyword in new[] { "properties", "patternProperties", "definitions", "$defs", "dependentSchemas" }) - { - if (obj[keyword] is JsonObject map) - { - foreach (var child in map.Select(property => property.Value)) - WalkSchema(child, root, selectedTypeId, context, mode, errors, depth + 1); - } - } - foreach (var keyword in new[] { "allOf", "anyOf", "oneOf", "prefixItems" }) + if (!obj.TryGetPropertyValue(keyword, out var operandNode)) + continue; + var kind = KindOf(keyword)!.Value; + if (!TryResolveOperand(kind, operandNode, selectedTypeId, out var operand, out var error)) { - if (obj[keyword] is JsonArray branches) - { - foreach (var child in branches) - WalkSchema(child, root, selectedTypeId, context, mode, errors, depth + 1); - } + errors.Add(error); + continue; } - foreach (var keyword in new[] { "items", "additionalItems", "additionalProperties", "contains", "not", "if", "then", "else", GtsSchemaKeywords.TraitsSchema }) - WalkSchema(obj[keyword], root, selectedTypeId, context, mode, errors, depth + 1); + // Pattern references never consult the registry (§9.6.2); the `/$id` self-reference + // resolves to the type being validated and is not a registry lookup. + if (kind != RefKind.Pattern && mode != GtsRefValidationMode.None && !operand.IsSelfId) + CheckOperandRegistry(operand, context, mode, errors); + } + + foreach (var keyword in new[] { "properties", "patternProperties", "definitions", "$defs", "dependentSchemas" }) + { + if (obj[keyword] is JsonObject map) + foreach (var child in map.Select(property => property.Value)) + WalkSchema(child, selectedTypeId, context, mode, errors, depth + 1); } + foreach (var keyword in new[] { "allOf", "anyOf", "oneOf", "prefixItems" }) + { + if (obj[keyword] is JsonArray branches) + foreach (var child in branches) + WalkSchema(child, selectedTypeId, context, mode, errors, depth + 1); + } + foreach (var keyword in new[] { "items", "additionalItems", "additionalProperties", "contains", "not", "if", "then", "else", GtsSchemaKeywords.TraitsSchema }) + WalkSchema(obj[keyword], selectedTypeId, context, mode, errors, depth + 1); } + private static void CheckOperandRegistry(Operand operand, RefContext context, GtsRefValidationMode mode, List errors) + { + var keyword = KeywordName(operand.Kind); + if (operand.IsWildcard) + { + var matches = context.Entities.Where(entity => operand.Pattern.Matches(entity.GtsId!)).ToList(); + if (matches.Count == 0) + errors.Add($"{keyword} wildcard operand '{operand.Pattern.Pattern}' has no registered match"); + else if (mode == GtsRefValidationMode.AnyValid && matches.All(entity => !context.ReferencedEntityValid(entity))) + errors.Add($"{keyword} wildcard operand '{operand.Pattern.Pattern}' has no valid registered match"); + return; + } + + var target = context.FindById(operand.ConcreteId!); + if (target is null) + errors.Add($"{keyword} operand '{operand.ConcreteId}' is not registered"); + else if (mode == GtsRefValidationMode.AnyValid && !context.ReferencedEntityValid(target)) + errors.Add($"{keyword} operand '{operand.ConcreteId}' is not valid"); + } + + // ---- instance walk: candidate / value validity ---------------------------------------- + private static void WalkInstance( JsonNode? instance, JsonNode? schema, @@ -101,12 +203,10 @@ private static void WalkInstance( HashSet localRefs, int depth) { - if (depth >= GtsConstants.MaxNestingDepth) - return; - if (schema is not JsonObject obj) + if (depth >= GtsConstants.MaxNestingDepth || schema is not JsonObject obj) return; - if (obj["$ref"] is JsonValue refValue && refValue.TryGetValue(out var reference) && reference.StartsWith('#') && localRefs.Add(reference)) + if (obj["$ref"] is JsonValue refValue && refValue.TryGetValue(out var reference) && reference.StartsWith('#') && localRefs.Add(reference + "\0" + path)) { if (!GtsJsonPointer.TryEvaluate(root, reference, out var target)) errors.Add($"Local reference '{reference}' not found"); @@ -114,43 +214,34 @@ private static void WalkInstance( WalkInstance(instance, target, root, selectedTypeId, context, mode, path, errors, localRefs, depth + 1); } - if (obj.TryGetPropertyValue(GtsSchemaKeywords.Ref, out var refNode)) - { - if (refNode is not JsonValue patternValue || !patternValue.TryGetValue(out var pattern) || - !TryResolvePattern(pattern, selectedTypeId, out var resolved)) - errors.Add($"{path}: invalid x-gts-ref constraint"); - else if (instance is not JsonValue instanceValue || !instanceValue.TryGetValue(out var referencedId)) - errors.Add($"{path}: x-gts-ref value must be a string"); - else if (!GtsId.TryParse(referencedId, out var parsed) || parsed is null || parsed.IsInstance && parsed.Segments.Count == 1) - errors.Add($"{path}: value is not a valid GTS identifier"); - else if (!Matches(referencedId, resolved)) - errors.Add($"{path}: value '{referencedId}' does not match pattern '{resolved}'"); - else if (mode != GtsRefValidationMode.None) - { - var referenced = context.FindById(referencedId); - if (referenced is null) - errors.Add($"{path}: referenced entity '{referencedId}' not found in registry"); - else if (mode == GtsRefValidationMode.AnyValid && !context.ReferencedEntityValid(referenced)) - errors.Add($"{path}: referenced entity '{referencedId}' is not valid against its GTS Type Schema"); - } + foreach (var keyword in GtsSchemaKeywords.ReferenceKeywords) + { + if (!obj.TryGetPropertyValue(keyword, out var operandNode)) + continue; + var kind = KindOf(keyword)!.Value; + // An invalid operand is a schema error reported by the schema walk; skip value checks. + if (!TryResolveOperand(kind, operandNode, selectedTypeId, out var operand, out _)) + continue; + CheckCandidate(instance, operand, context, mode, path, errors); } foreach (var keyword in new[] { "oneOf", "anyOf" }) { if (obj[keyword] is not JsonArray branches) continue; - // Only branches that actually declare an x-gts-ref (directly or nested) participate - // in the reference-match count. A branch without one - e.g. the {"type":"null"} arm of a - // nullable reference - is neutral: this pass never inspects its structural keywords, so it - // would otherwise always look like a match and make a valid value fail oneOf's exactly-one - // rule. Structural oneOf/anyOf selection is left to the JSON Schema engine. + // Only branches that actually declare a reference keyword participate in the match count; + // a branch without one (e.g. the {"type":"null"} arm of a nullable reference) is neutral + // and evaluated by the JSON Schema engine. A neutral branch only suppresses a reference + // failure when it could actually match this value: a {"type":"null"} branch cannot rescue + // a string value, so its sibling reference branches must still be satisfied. var refBranchResults = new List>(); var hasNeutralBranch = false; foreach (var branch in branches) { - if (!ContainsXGtsRef(branch)) + if (!ContainsReferenceKeyword(branch)) { - hasNeutralBranch = true; + if (BranchTypeAdmits(branch, instance)) + hasNeutralBranch = true; continue; } var branchErrors = new List(); @@ -159,77 +250,229 @@ private static void WalkInstance( } var matching = refBranchResults.Count(result => result.Count == 0); if (refBranchResults.Count > 0 && (matching == 0 && !hasNeutralBranch || keyword == "oneOf" && matching > 1)) - errors.Add($"{path}: {keyword}: expected {(keyword == "oneOf" ? "exactly one" : "at least one")} matching x-gts-ref branch"); + errors.Add($"{path}: {keyword}: expected {(keyword == "oneOf" ? "exactly one" : "at least one")} matching reference branch"); } if (obj["allOf"] is JsonArray allOf) - { foreach (var branch in allOf) WalkInstance(instance, branch, root, selectedTypeId, context, mode, path, errors, new HashSet(localRefs), depth + 1); - } - if (instance is JsonObject instanceObject && obj["properties"] is JsonObject properties) + // `not` inverts a reference value-verdict (§9.6.3): if the negated subschema's reference + // constraints are satisfied, `not` fails. Neutral subschemas (no reference keyword) are left + // to the JSON Schema engine. Nested `not` composes through recursion. + if (obj["not"] is JsonObject notSchema && ContainsReferenceKeyword(notSchema)) { - foreach (var (name, propertySchema) in properties) - { - if (instanceObject.TryGetPropertyValue(name, out var propertyValue)) - WalkInstance(propertyValue, propertySchema, root, selectedTypeId, context, mode, - string.IsNullOrEmpty(path) ? name : path + "." + name, errors, new HashSet(localRefs), depth + 1); - } + var notErrors = new List(); + WalkInstance(instance, notSchema, root, selectedTypeId, context, mode, path, notErrors, new HashSet(localRefs), depth + 1); + if (notErrors.Count == 0 && StructurallyAccepts(instance, notSchema, root)) + errors.Add($"{path}: not: reference constraint unexpectedly satisfied"); } + + foreach (var keyword in new[] { "if", "then", "else" }) + if (obj[keyword] is JsonObject conditional && ContainsReferenceKeyword(conditional)) + WalkInstance(instance, conditional, root, selectedTypeId, context, mode, path, errors, new HashSet(localRefs), depth + 1); + + if (instance is JsonObject instanceObject) + WalkObject(instanceObject, obj, root, selectedTypeId, context, mode, path, errors, localRefs, depth); + if (instance is JsonArray instanceArray) + WalkArray(instanceArray, obj, root, selectedTypeId, context, mode, path, errors, localRefs, depth); + } + + private static void WalkObject( + JsonObject instanceObject, JsonObject obj, JsonObject root, string selectedTypeId, + RefContext context, GtsRefValidationMode mode, string path, List errors, HashSet localRefs, int depth) + { + var properties = obj["properties"] as JsonObject; + var patternProperties = obj["patternProperties"] as JsonObject; + foreach (var (name, value) in instanceObject) { - if (obj["prefixItems"] is JsonArray prefixItems) - { - for (var index = 0; index < Math.Min(instanceArray.Count, prefixItems.Count); index++) - WalkInstance(instanceArray[index], prefixItems[index], root, selectedTypeId, context, mode, $"{path}[{index}]", errors, new HashSet(localRefs), depth + 1); - if (obj["items"] is JsonObject overflowItems) - { - for (var index = prefixItems.Count; index < instanceArray.Count; index++) - WalkInstance(instanceArray[index], overflowItems, root, selectedTypeId, context, mode, $"{path}[{index}]", errors, new HashSet(localRefs), depth + 1); - } - } - else if (obj["items"] is JsonArray tupleItems) + var childPath = string.IsNullOrEmpty(path) ? name : path + "." + name; + var covered = false; + if (properties is not null && properties.TryGetPropertyValue(name, out var propertySchema)) { - for (var index = 0; index < Math.Min(instanceArray.Count, tupleItems.Count); index++) - WalkInstance(instanceArray[index], tupleItems[index], root, selectedTypeId, context, mode, $"{path}[{index}]", errors, new HashSet(localRefs), depth + 1); - if (obj["additionalItems"] is JsonObject additional) - { - for (var index = tupleItems.Count; index < instanceArray.Count; index++) - WalkInstance(instanceArray[index], additional, root, selectedTypeId, context, mode, $"{path}[{index}]", errors, new HashSet(localRefs), depth + 1); - } + covered = true; + WalkInstance(value, propertySchema, root, selectedTypeId, context, mode, childPath, errors, new HashSet(localRefs), depth + 1); } - else if (obj["items"] is JsonObject items) + if (patternProperties is not null) { - for (var index = 0; index < instanceArray.Count; index++) - WalkInstance(instanceArray[index], items, root, selectedTypeId, context, mode, $"{path}[{index}]", errors, new HashSet(localRefs), depth + 1); + foreach (var (key, patternSchema) in patternProperties) + if (NameMatchesPattern(key, name)) + { + covered = true; + WalkInstance(value, patternSchema, root, selectedTypeId, context, mode, childPath, errors, new HashSet(localRefs), depth + 1); + } } + if (!covered && obj["additionalProperties"] is JsonObject additionalProperties) + WalkInstance(value, additionalProperties, root, selectedTypeId, context, mode, childPath, errors, new HashSet(localRefs), depth + 1); + if (!covered && obj["unevaluatedProperties"] is JsonObject unevaluatedProperties) + WalkInstance(value, unevaluatedProperties, root, selectedTypeId, context, mode, childPath, errors, new HashSet(localRefs), depth + 1); + } + } + + private static bool NameMatchesPattern(string pattern, string name) + { + try + { + return GtsRegexCompiler.Compile(pattern).IsMatch(name); + } + catch (GtsRegexException) + { + return false; + } + } + + private static void WalkArray( + JsonArray instanceArray, JsonObject obj, JsonObject root, string selectedTypeId, + RefContext context, GtsRefValidationMode mode, string path, List errors, HashSet localRefs, int depth) + { + if (obj["prefixItems"] is JsonArray prefixItems) + { + for (var index = 0; index < Math.Min(instanceArray.Count, prefixItems.Count); index++) + WalkInstance(instanceArray[index], prefixItems[index], root, selectedTypeId, context, mode, $"{path}[{index}]", errors, new HashSet(localRefs), depth + 1); + if (obj["items"] is JsonObject overflowItems) + for (var index = prefixItems.Count; index < instanceArray.Count; index++) + WalkInstance(instanceArray[index], overflowItems, root, selectedTypeId, context, mode, $"{path}[{index}]", errors, new HashSet(localRefs), depth + 1); + } + else if (obj["items"] is JsonArray tupleItems) + { + for (var index = 0; index < Math.Min(instanceArray.Count, tupleItems.Count); index++) + WalkInstance(instanceArray[index], tupleItems[index], root, selectedTypeId, context, mode, $"{path}[{index}]", errors, new HashSet(localRefs), depth + 1); + if (obj["additionalItems"] is JsonObject additional) + for (var index = tupleItems.Count; index < instanceArray.Count; index++) + WalkInstance(instanceArray[index], additional, root, selectedTypeId, context, mode, $"{path}[{index}]", errors, new HashSet(localRefs), depth + 1); + } + else if (obj["items"] is JsonObject items) + { + for (var index = 0; index < instanceArray.Count; index++) + WalkInstance(instanceArray[index], items, root, selectedTypeId, context, mode, $"{path}[{index}]", errors, new HashSet(localRefs), depth + 1); + } + } + + /// Checks a candidate value against a resolved reference operand (ordinary value failure on mismatch). + private static void CheckCandidate(JsonNode? instance, Operand operand, RefContext context, GtsRefValidationMode mode, string path, List errors) + { + // Reference keywords constrain strings only; other JSON types are not constrained here. + if (instance is not JsonValue value || !value.TryGetValue(out var candidate)) + return; + var keyword = KeywordName(operand.Kind); + + if (operand.Kind == RefKind.Pattern) + { + if (!GtsPattern.TryParse(candidate, out var candidatePattern)) + errors.Add($"{path}: '{candidate}' is not a valid GTS identifier pattern"); + else if (!operand.Pattern.Covers(candidatePattern!)) + errors.Add($"{path}: '{candidate}' selects GTS identifiers outside '{operand.Pattern.Pattern}'"); + return; + } + + // Candidate must be a concrete canonical identifier — not a pattern or a gts:// URI. + // Kind is read from the spelling (Type IDs end in `~`), not the int-based GtsId parser, + // so unsigned 32-bit versions are accepted. + if (!GtsPattern.TryParse(candidate, out var concrete) || concrete!.HasWildcard) + { + errors.Add($"{path}: value '{candidate}' is not a valid GTS identifier"); + return; + } + var candidateIsType = candidate.EndsWith('~'); + var requireType = operand.Kind == RefKind.Type; + if (candidateIsType != requireType) + { + errors.Add($"{path}: value '{candidate}' is a GTS {(candidateIsType ? "Type" : "Instance")} Identifier, but {keyword} requires a GTS {(requireType ? "Type" : "Instance")} Identifier"); + return; + } + if (!operand.Pattern.MatchesCandidate(candidate)) + { + errors.Add($"{path}: value '{candidate}' does not match pattern '{operand.Pattern.Pattern}'"); + return; + } + if (mode != GtsRefValidationMode.None) + { + var referenced = context.FindById(candidate); + if (referenced is null) + errors.Add($"{path}: referenced entity '{candidate}' is not registered"); + else if (mode == GtsRefValidationMode.AnyValid && !context.ReferencedEntityValid(referenced)) + errors.Add($"{path}: referenced entity '{candidate}' is not valid against its GTS Type Schema"); } } - private static bool TryResolvePattern(string pattern, string selectedTypeId, out string resolved) + /// + /// Whether 's type constraint (if any) admits the JSON type of + /// . Used to decide whether a reference-free combinator branch could + /// match the value; a branch with no type is assumed to possibly match. + /// + private static bool BranchTypeAdmits(JsonNode? branch, JsonNode? instance) { - var valid = GtsRefConstraint.TryCreate(pattern, selectedTypeId, out var constraint); - resolved = valid ? constraint.Pattern : pattern; - return valid; + if (branch is not JsonObject obj || !obj.TryGetPropertyValue("type", out var typeNode)) + return true; + var actual = JsonTypeName(instance); + bool Admits(JsonNode? t) => t is JsonValue v && v.TryGetValue(out var name) && + (name == actual || (name == "number" && actual == "integer")); + return typeNode switch + { + JsonArray types => types.Any(Admits), + _ => Admits(typeNode), + }; } - private static bool Matches(string value, string pattern) => new GtsRefConstraint(pattern).Matches(value); + private static string JsonTypeName(JsonNode? node) + { + switch (node) + { + case null: + return "null"; + case JsonObject: + return "object"; + case JsonArray: + return "array"; + case JsonValue value: + return value.GetValueKind() switch + { + System.Text.Json.JsonValueKind.String => "string", + System.Text.Json.JsonValueKind.True or System.Text.Json.JsonValueKind.False => "boolean", + System.Text.Json.JsonValueKind.Null => "null", + // JSON Schema treats any number with a zero fractional part (e.g. 1.0) as an integer. + System.Text.Json.JsonValueKind.Number => value.GetValue() is var number && double.IsFinite(number) && Math.Floor(number) == number ? "integer" : "number", + _ => "", + }; + default: + return ""; + } + } /// - /// Whether a schema node carries an x-gts-ref declaration anywhere within it, so that - /// combinator branches without one can be treated as neutral rather than always-matching. + /// Whether the JSON Schema engine accepts against the non-reference + /// part of . The engine never sees a reference-bearing not, + /// so its structural constraints (e.g. type) are checked here before inverting. Root + /// definitions and dialect are carried over so local references resolve. Fails closed (assumes + /// acceptance, so not rejects) when the subschema cannot be evaluated. /// - private static bool ContainsXGtsRef(JsonNode? node) => node switch + private static bool StructurallyAccepts(JsonNode? instance, JsonObject subschema, JsonObject root) + { + var document = (JsonObject)subschema.DeepClone(); + foreach (var key in new[] { "$schema", "$$schema", "$defs", "$$defs", "definitions" }) + if (root[key] is { } value && !document.ContainsKey(key)) + document[key] = value.DeepClone(); + try + { + return GtsJsonSchemaEvaluator.EvaluateInline(instance, GtsSchemaDocumentNormalizer.ForJsonSchemaEvaluation(document)).IsValid; + } + catch (Json.Schema.JsonSchemaException) + { + return true; + } + } + + /// Whether a schema node carries any of the three reference keywords (directly or nested). + private static bool ContainsReferenceKeyword(JsonNode? node) => node switch { - JsonObject obj => obj.ContainsKey(GtsSchemaKeywords.Ref) || obj.Any(property => ContainsXGtsRef(property.Value)), - JsonArray array => array.Any(ContainsXGtsRef), + JsonObject obj => GtsSchemaKeywords.ReferenceKeywords.Any(obj.ContainsKey) || obj.Any(property => ContainsReferenceKeyword(property.Value)), + JsonArray array => array.Any(ContainsReferenceKeyword), _ => false, }; /// - /// Per-validation lookup context: an id -> entity index (built once), a lazily-built map of - /// evaluation-normalized schemas, and a memo of referenced-entity validity. This replaces the previous - /// per-reference linear scans and per-reference schema-map rebuilds (which were O(N) each, O(N^2) overall). + /// Per-validation lookup context: an id -> entity index, a lazily-built map of + /// evaluation-normalized schemas, and a memo of referenced-entity validity. /// private sealed class RefContext { @@ -243,7 +486,6 @@ private RefContext(List entities, DictionaryEntities that carry a GTS id (the only ones referenceable by x-gts-ref). public List Entities { get; } public static RefContext Create(IEnumerable entities) @@ -264,13 +506,10 @@ private Dictionary NormalizedSchemas() { if (_normalizedSchemas is not null) return _normalizedSchemas; - _normalizedSchemas = new Dictionary(); foreach (var entity in Entities) - { if (entity.IsSchema && entity.GtsId is not null) _normalizedSchemas[entity.GtsId] = GtsSchemaDocumentNormalizer.ForJsonSchemaEvaluation(entity.Content); - } return _normalizedSchemas; } @@ -279,7 +518,6 @@ public bool ReferencedEntityValid(GtsJsonEntity entity) var key = entity.GtsId?.Id; if (key is not null && _validity.TryGetValue(key, out var cached)) return cached; - var result = ComputeValidity(entity); if (key is not null) _validity[key] = result; @@ -289,11 +527,23 @@ public bool ReferencedEntityValid(GtsJsonEntity entity) private bool ComputeValidity(GtsJsonEntity entity) { if (entity.IsSchema && entity.GtsId is not null) - return GtsSchemaTraitsValidator.Validate(entity.GtsId, entity.Content, LoadSchema, Entities, GtsRefValidationMode.None).Count == 0; + { + // A type target is valid only if its derivation holds (e.g. it does not derive from a + // final ancestor) and its traits validate (§9.6.1 "any-valid"). + var (derivationOk, _) = GtsSchemaDerivationValidator.ValidateAgainstRegistry(entity.GtsId, entity.Content, LoadSchema); + return derivationOk && + GtsSchemaTraitsValidator.Validate(entity.GtsId, entity.Content, LoadSchema, Entities, GtsRefValidationMode.None).Count == 0; + } if (string.IsNullOrEmpty(entity.SchemaId) || !GtsId.TryParse(entity.SchemaId, out var schemaId) || schemaId is null) return false; + // An instance of an x-gts-abstract type is not a valid entity (abstract types are non-instantiable). + if (LoadSchema(schemaId) is { } typeSchema && + typeSchema[GtsSchemaKeywords.Abstract] is JsonValue abstractValue && + abstractValue.TryGetValue(out var isAbstract) && isAbstract) + return false; + var schemas = NormalizedSchemas(); if (!schemas.ContainsKey(schemaId)) return false; diff --git a/Gts.Store/GtsSchemaDependencyGraph.cs b/Gts.Store/GtsSchemaDependencyGraph.cs index dd59bc8..01359a0 100644 --- a/Gts.Store/GtsSchemaDependencyGraph.cs +++ b/Gts.Store/GtsSchemaDependencyGraph.cs @@ -20,7 +20,34 @@ internal static string Dialect(JsonObject schema) : "unsupported:" + (document["$schema"]?.ToJsonString() ?? ""); } - internal static JsonObject Resolve(JsonObject schema, Func load) => Resolve(schema, load, new HashSet(), 0); + /// Upper bound on schema nodes produced by inlining, so shared references cannot blow up exponentially. + internal const int MaxResolvedNodes = 100_000; + + /// Keywords whose values are literal data, never schemas; copied verbatim. + private static readonly HashSet LiteralKeywords = new(StringComparer.Ordinal) + { + "const", "enum", "default", "examples", GtsSchemaKeywords.Traits, + }; + + private static readonly HashSet SchemaMapKeywords = new(StringComparer.Ordinal) + { + "properties", "patternProperties", "definitions", "$defs", "dependentSchemas", + }; + + /// Inlining exceeds . + internal static JsonObject Resolve(JsonObject schema, Func load) => + Resolve(schema, load, new HashSet(), 0, new Budget()); + + private sealed class Budget + { + private int _nodes; + + internal void Spend() + { + if (++_nodes > MaxResolvedNodes) + throw new GtsSchemaTooLargeException(); + } + } internal static IEnumerable References(JsonNode? node) { @@ -89,8 +116,9 @@ private static bool HasCycle(string id, JsonObject schema, Func load, HashSet stack, int depth) + private static JsonObject Resolve(JsonObject schema, Func load, HashSet stack, int depth, Budget budget) { + budget.Spend(); // Inlining $ref targets can build a tree deeper than any single input document (a chain of N // distinct type schemas resolves to depth ~N), so cap it independently of the JSON parser's // own depth limit. Beyond the cap we stop inlining and return the node as-is. @@ -112,26 +140,75 @@ private static JsonObject Resolve(JsonObject schema, Func lo : document; if (target is not null) { - var resolved = Resolve(target, load, stack, depth + 1); + // Local `#...` references in the loaded document are relative to it, not to the + // document it is inlined into, so resolve them before inlining. + target = InlineLocalRefs(target, document, new HashSet(), depth, budget); + var resolved = Resolve(target, load, stack, depth + 1, budget); stack.Remove(reference); var siblings = new JsonObject(); foreach (var (key, value) in schema) if (key != "$ref") siblings[key] = value?.DeepClone(); if (siblings.Count == 0) return resolved; - return new JsonObject { ["allOf"] = new JsonArray(resolved, Resolve(siblings, load, stack, depth + 1)) }; + return new JsonObject { ["allOf"] = new JsonArray(resolved, Resolve(siblings, load, stack, depth + 1, budget)) }; } stack.Remove(reference); } } + return CloneWith(schema, child => Resolve(child, load, stack, depth + 1, budget)); + } + + /// + /// Clones , replacing local #... references with their targets in + /// . A recursive local reference is left as-is. + /// + private static JsonObject InlineLocalRefs(JsonObject schema, JsonObject document, HashSet active, int depth, Budget budget) + { + budget.Spend(); + if (depth >= GtsConstants.MaxNestingDepth) + return (JsonObject)schema.DeepClone(); + if (schema["$ref"] is JsonValue refValue && refValue.TryGetValue(out var reference) && + reference.StartsWith('#') && !active.Contains(reference) && + GtsJsonPointer.TryEvaluate(document, reference, out var pointed) && pointed is JsonObject target) + { + active.Add(reference); + var resolved = InlineLocalRefs(target, document, active, depth + 1, budget); + active.Remove(reference); + var siblings = new JsonObject(); + foreach (var (key, value) in schema) + if (key != "$ref") siblings[key] = value?.DeepClone(); + if (siblings.Count == 0) + return resolved; + return new JsonObject { ["allOf"] = new JsonArray(resolved, InlineLocalRefs(siblings, document, active, depth + 1, budget)) }; + } + return CloneWith(schema, child => InlineLocalRefs(child, document, active, depth + 1, budget)); + } + + /// Clones a schema object, mapping nested objects with except under literal-data keywords. + private static JsonObject CloneWith(JsonObject schema, Func map) + { var clone = new JsonObject(); foreach (var (key, value) in schema) - clone[key] = value switch + clone[key] = LiteralKeywords.Contains(key) ? value?.DeepClone() : value switch { - JsonObject obj => Resolve(obj, load, stack, depth + 1), - JsonArray array => new JsonArray(array.Select(item => item is JsonObject child ? Resolve(child, load, stack, depth + 1) : item?.DeepClone()).ToArray()), + // Members of a schema map are schemas even when named like a literal keyword. + JsonObject members when SchemaMapKeywords.Contains(key) => MapMembers(members, map), + JsonObject obj => map(obj), + JsonArray array => new JsonArray(array.Select(item => item is JsonObject child ? map(child) : item?.DeepClone()).ToArray()), _ => value?.DeepClone() }; return clone; } + + private static JsonObject MapMembers(JsonObject members, Func map) + { + var clone = new JsonObject(); + foreach (var (name, member) in members) + clone[name] = member is JsonObject child ? map(child) : member?.DeepClone(); + return clone; + } } + +/// Resolving a schema's references would exceed . +internal sealed class GtsSchemaTooLargeException() + : Exception($"Resolved schema exceeds {GtsSchemaDependencyGraph.MaxResolvedNodes} nodes"); diff --git a/Gts.Store/GtsSchemaDerivationValidator.cs b/Gts.Store/GtsSchemaDerivationValidator.cs index 0e2f01c..78d5229 100644 --- a/Gts.Store/GtsSchemaDerivationValidator.cs +++ b/Gts.Store/GtsSchemaDerivationValidator.cs @@ -12,6 +12,19 @@ public static (bool Ok, IReadOnlyList Errors) ValidateAgainstRegistry( Func tryLoadTypeSchema) { var errors = new List(); + try + { + WalkChain(derivedId, derivedSchema, tryLoadTypeSchema, errors); + } + catch (GtsSchemaTooLargeException exception) + { + errors.Add(exception.Message); + } + return (errors.Count == 0, errors); + } + + private static void WalkChain(GtsId derivedId, JsonObject derivedSchema, Func tryLoadTypeSchema, List errors) + { var currentId = derivedId.Id; var currentSchema = derivedSchema; @@ -50,11 +63,15 @@ public static (bool Ok, IReadOnlyList Errors) ValidateAgainstRegistry( ValidateRedeclarations(parentFlat, Flatten(currentSchema), "", errors); ValidateClosedBranches(parentFlat, currentSchema, "", errors); + // OP#12 must preserve Valid(derived) ⊆ Valid(base) on the reference dimension (§9.6.1): + // the derived property's kind-filtered scope / pattern coverage must be included in the + // base's. Unknown fails closed. `/$id` resolves in each schema's own context. + if (GtsRefScopeCompat.SchemaRefRelation(resolvedParent, resolvedChild, parentIdStr, currentId) != GtsRefScopeCompat.Relation.Included) + errors.Add($"Derived schema '{currentId}' narrows or alters a GTS reference constraint of precedent '{parentIdStr}' in a way that is not provably within the base's accepted set"); + currentId = parentIdStr; currentSchema = parentSchema; } - - return (errors.Count == 0, errors); } internal static JsonObject ResolveForEvaluation(JsonObject schema, Func loadSchema) => @@ -188,7 +205,7 @@ private static void MergeKeyword(JsonObject target, string key, JsonNode? value) targetProperties[name] = merged; } else - targetProperties[name] = property?.DeepClone(); + targetProperties[name] = property is JsonObject propObj ? Flatten(propObj) : property?.DeepClone(); } target["properties"] = targetProperties; return; @@ -235,6 +252,9 @@ private static void ValidateSubset(JsonNode? parent, JsonNode? child, string pat if (parent is not JsonObject parentObject || child is not JsonObject childObject) return; + parentObject = Flatten(parentObject); + childObject = Flatten(childObject); + ValidateTypes(parentObject, childObject, path, errors); ValidateEnumAndConst(parentObject, childObject, path, errors); ValidateBounds(parentObject, childObject, path, errors); diff --git a/Gts.Store/GtsSchemaKeywordValidator.cs b/Gts.Store/GtsSchemaKeywordValidator.cs index 2f14885..0af0133 100644 --- a/Gts.Store/GtsSchemaKeywordValidator.cs +++ b/Gts.Store/GtsSchemaKeywordValidator.cs @@ -10,10 +10,14 @@ public static class GtsSchemaKeywordValidator GtsSchemaKeywords.Final, GtsSchemaKeywords.Abstract, GtsSchemaKeywords.Traits, GtsSchemaKeywords.TraitsSchema ]; + // Property-level GTS keywords that are part of the supported vocabulary (§9.6). Any other + // `x-gts-*` key (including the removed `x-gts-ref`) is an unknown keyword and rejected (§11.0). + private static readonly HashSet Recognized = [.. GtsSchemaKeywords.ReferenceKeywords]; + public static IReadOnlyList Validate(JsonObject schema) { var errors = new List(); - GtsSchemaWalker.Visit(schema, (node, root) => ValidateNode(node, root, errors)); + GtsSchemaWalker.Visit(schema, (node, root) => ValidateNode(node, root, errors), includeTraitsSchema: true); if (schema[GtsSchemaKeywords.Final] is JsonNode final && !TryBoolean(final, out _)) errors.Add("x-gts-final must be a boolean"); if (schema[GtsSchemaKeywords.Abstract] is JsonNode abstractNode && !TryBoolean(abstractNode, out _)) @@ -34,7 +38,7 @@ private static void ValidateNode(JsonObject schema, bool root, List erro foreach (var key in schema.Select(property => property.Key)) { - if (key.StartsWith(GtsSchemaKeywords.Prefix, StringComparison.Ordinal) && key != GtsSchemaKeywords.Ref && !TopLevel.Contains(key)) + if (key.StartsWith(GtsSchemaKeywords.Prefix, StringComparison.Ordinal) && !Recognized.Contains(key) && !TopLevel.Contains(key)) errors.Add($"Unsupported GTS extension keyword: {key}"); if (!root && TopLevel.Contains(key)) errors.Add($"{key} must be at the schema top level"); diff --git a/Gts.Store/GtsSchemaKeywords.cs b/Gts.Store/GtsSchemaKeywords.cs index 8b96ff8..124d79a 100644 --- a/Gts.Store/GtsSchemaKeywords.cs +++ b/Gts.Store/GtsSchemaKeywords.cs @@ -1,3 +1,5 @@ +using System.Collections.Immutable; + namespace Gts.Store; /// @@ -9,8 +11,17 @@ public static class GtsSchemaKeywords /// Common prefix shared by every GTS schema extension keyword. public const string Prefix = "x-gts-"; - /// x-gts-ref: constrains a string value to a GTS identifier matching a pattern (§9.6). - public const string Ref = "x-gts-ref"; + /// x-gts-type-ref: constrains a string to a GTS Type Identifier (§9.6.1). + public const string TypeRef = "x-gts-type-ref"; + + /// x-gts-instance-ref: constrains a string to a GTS Instance Identifier (§9.6.1). + public const string InstanceRef = "x-gts-instance-ref"; + + /// x-gts-pattern-ref: constrains a string holding a GTS identifier pattern (§9.6.2). + public const string PatternRef = "x-gts-pattern-ref"; + + /// The three GTS identifier-reference keywords (§9.6). Immutable: shared process-wide. + public static readonly ImmutableArray ReferenceKeywords = [TypeRef, InstanceRef, PatternRef]; /// x-gts-final: marks a type schema as non-derivable. public const string Final = "x-gts-final"; diff --git a/Gts.Store/Validation/GtsSchemaDocumentNormalizer.cs b/Gts.Store/Validation/GtsSchemaDocumentNormalizer.cs index ae4ecfe..b2d201d 100644 --- a/Gts.Store/Validation/GtsSchemaDocumentNormalizer.cs +++ b/Gts.Store/Validation/GtsSchemaDocumentNormalizer.cs @@ -65,7 +65,8 @@ private static void StripXGtsRefDeep(JsonNode? node) { if (node is not JsonObject obj) return; - obj.Remove(GtsSchemaKeywords.Ref); + foreach (var refKeyword in GtsSchemaKeywords.ReferenceKeywords) + obj.Remove(refKeyword); foreach (var keyword in new[] { "properties", "patternProperties", "definitions", "$defs", "dependentSchemas" }) { if (obj[keyword] is JsonObject map) @@ -87,7 +88,7 @@ private static void StripXGtsRefDeep(JsonNode? node) // NOTE: x-gts-ref is still enforced by GtsRefValidator rather than by the // JsonSchema.Net dialect. This localized rewrite keeps oneOf branch behavior // consistent until x-gts-ref is migrated to a first-class keyword handler. - var hadRef = branches.OfType().Any(branch => branch.ContainsKey(GtsSchemaKeywords.Ref)); + var hadRef = branches.OfType().Any(branch => GtsSchemaKeywords.ReferenceKeywords.Any(branch.ContainsKey)); foreach (var child in branches) StripXGtsRefDeep(child); if (branches.Count > 0 && branches.All(branch => branch is JsonObject branchObject && branchObject.Count == 0)) @@ -101,11 +102,27 @@ private static void StripXGtsRefDeep(JsonNode? node) } foreach (var keyword in new[] { "items", "additionalItems", "additionalProperties", "contains", "not", "if", "then", "else" }) { - if (obj[keyword] is JsonObject child) - StripXGtsRefDeep(child); + if (obj[keyword] is not JsonObject child) + continue; + var hadRef = ContainsReferenceKeyword(child); + StripXGtsRefDeep(child); + // `not` whose subschema carries a reference keyword is dropped from the engine's view: + // the engine cannot see reference keywords, so a negated subschema like + // {"type":"string","x-gts-pattern-ref":...} would collapse to "not a string" and reject + // every string. GtsRefValidator applies the full `not` inversion (including the + // structural part that only constrains strings) against the unstripped schema instead. + if (keyword == "not" && hadRef) + obj.Remove("not"); } } + private static bool ContainsReferenceKeyword(JsonNode? node) => node switch + { + JsonObject obj => GtsSchemaKeywords.ReferenceKeywords.Any(obj.ContainsKey) || obj.Any(property => ContainsReferenceKeyword(property.Value)), + JsonArray array => array.Any(ContainsReferenceKeyword), + _ => false, + }; + private static void RewriteGtsUrisDeep(JsonNode? node) { switch (node) diff --git a/Gts.Tests/Validation/ReferenceResolutionHardeningTests.cs b/Gts.Tests/Validation/ReferenceResolutionHardeningTests.cs new file mode 100644 index 0000000..c8383ad --- /dev/null +++ b/Gts.Tests/Validation/ReferenceResolutionHardeningTests.cs @@ -0,0 +1,98 @@ +using System.Text.Json.Nodes; +using Gts.Extraction; +using Gts.Store; + +namespace Gts.Tests.Validation; + +public class ReferenceResolutionHardeningTests +{ + private const string Draft07 = "http://json-schema.org/draft-07/schema#"; + private const string TargetId = "gts.x.test.hardening.target.v1~"; + + private static JsonObject Parse(string json) => JsonNode.Parse(json)!.AsObject(); + + private static JsonObject ResolveThrough(JsonObject target) => + GtsSchemaDependencyGraph.Resolve( + Parse($$"""{ "$ref": "gts://{{TargetId}}" }"""), + id => id.Id == TargetId ? target : null); + + [Fact] + public void Resolve_rejects_exponential_reference_fanout() + { + var defs = new JsonObject(); + const int levels = 30; + for (var i = 0; i < levels; i++) + defs[$"d{i}"] = Parse($$"""{ "allOf": [{ "$ref": "#/$defs/d{{i + 1}}" }, { "$ref": "#/$defs/d{{i + 1}}" }] }"""); + defs[$"d{levels}"] = Parse("""{ "type": "string" }"""); + var target = new JsonObject { ["$defs"] = defs, ["$ref"] = "#/$defs/d0" }; + + Assert.Throws(() => ResolveThrough(target)); + } + + [Fact] + public void Resolve_keeps_literal_data_and_resolves_schema_map_members() + { + var target = Parse(""" + { + "$defs": { "x": { "type": "string" } }, + "properties": { + "literal": { "const": { "$ref": "#/$defs/x" } }, + "default": { "$ref": "#/$defs/x" } + } + } + """); + + var resolved = ResolveThrough(target); + + var properties = resolved["properties"]!.AsObject(); + Assert.Equal("#/$defs/x", properties["literal"]!["const"]!["$ref"]!.GetValue()); + Assert.Equal("string", properties["default"]!["type"]!.GetValue()); + } + + private static IReadOnlyList ValidateInstance(string instance, string schema, string selected = "gts.x.test.hardening.leaf.v1~") => + GtsRefValidator.ValidateInstance(Parse(instance), Parse(schema), selected, Array.Empty(), GtsRefValidationMode.None); + + [Fact] + public void Not_with_reference_keyword_admits_values_of_another_type() + { + var schema = $$""" + { + "$schema": "{{Draft07}}", + "properties": { "scope": { "not": { "type": "string", "x-gts-pattern-ref": "{{TargetId}}" } } } + } + """; + + Assert.Empty(ValidateInstance("""{ "scope": 5 }""", schema)); + Assert.NotEmpty(ValidateInstance($$"""{ "scope": "{{TargetId}}" }""", schema)); + } + + [Fact] + public void Recursive_local_reference_checks_every_level() + { + var schema = $$""" + { + "$schema": "{{Draft07}}", + "definitions": { + "node": { + "properties": { + "id": { "type": "string", "x-gts-type-ref": "{{TargetId}}" }, + "child": { "$ref": "#/definitions/node" } + } + } + }, + "$ref": "#/definitions/node" + } + """; + + Assert.Empty(ValidateInstance($$"""{ "child": { "child": { "id": "{{TargetId}}" } } }""", schema)); + Assert.NotEmpty(ValidateInstance("""{ "child": { "child": { "id": "gts.x.test.hardening.other.v1~" } } }""", schema)); + } + + [Fact] + public void Self_reference_accepts_unsigned_32_bit_versions() + { + var schema = Parse("""{ "properties": { "scope": { "type": "string", "x-gts-pattern-ref": "/$id" } } }"""); + + Assert.Empty(GtsRefValidator.ValidatePatterns(schema, "gts.x.test.hardening.leaf.v4294967295~")); + } +} From 1a568d88af7b41ee78826039691ef891f361c159 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sat, 24 Oct 2026 20:10:45 +0300 Subject: [PATCH 4/4] docs: update README.md Signed-off-by: Artifizer --- README.md | 372 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 299 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 2734c58..46c0b2a 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,11 @@ # GTS .NET Library -An idiomatic C#/.NET library for working with **GTS** ([Global Type System](https://github.com/gts-spec/gts-spec)) identifiers and JSON/JSON Schema artifacts. +An idiomatic C#/.NET library for working with **GTS** ([Global Type System](https://github.com/GlobalTypeSystem/gts-spec)) identifiers and JSON/JSON Schema artifacts. -Supported GTS spec version: `v0.14.5` (pinned in [`.gts-spec-version`](.gts-spec-version)) +Supported GTS spec version: `v0.17.0` + +The conformance image is pinned to the released `v0.17.0` in [`.gts-spec-version`](.gts-spec-version). ## Roadmap @@ -16,126 +18,350 @@ Featureset: - [x] **OP#4 - ID Pattern Matching**: Match identifiers against patterns containing wildcards - [x] **OP#5 - ID to UUID Mapping**: Generate deterministic UUIDs from GTS identifiers - [x] **OP#6 - Instance Validation**: Validate object instances against their corresponding schemas -- [ ] **OP#7 - Relationship Resolution**: Load all schemas and instances, resolve inter-dependencies, and detect broken references -- [ ] **OP#8 - Compatibility Checking**: Verify that schemas with different MINOR versions are compatible -- [ ] **OP#8.1 - Backward compatibility checking** -- [ ] **OP#8.2 - Forward compatibility checking** -- [ ] **OP#8.3 - Full compatibility checking** -- [ ] **OP#9 - Version Casting**: Transform instances between compatible MINOR versions -- [ ] **OP#10 - Query Execution**: Filter identifier collections using the GTS query language -- [ ] **OP#11 - Attribute Access**: Retrieve property values and metadata using the attribute selector (`@`) -- [ ] **OP#12 - Schema Validation**: Validate schema against its precedent schema +- [x] **OP#7 - Relationship Resolution**: Load all schemas and instances, resolve inter-dependencies, and detect broken references +- [x] **OP#8 - Compatibility Checking**: Verify that schemas with different MINOR versions are compatible +- [x] **OP#8.1 - Backward compatibility checking** +- [x] **OP#8.2 - Forward compatibility checking** +- [x] **OP#8.3 - Full compatibility checking** +- [x] **OP#9 - Version Casting**: Transform instances between compatible MINOR versions +- [x] **OP#10 - Query Execution**: Filter identifier collections using the GTS query language +- [x] **OP#11 - Attribute Access**: Retrieve property values and metadata using the attribute selector (`@`) +- [x] **OP#12 - Schema Validation**: Validate schema against its precedent schema +- [x] **OP#13 - Schema Traits Validation**: Validate `x-gts-traits-schema` / `x-gts-traits` across the inheritance chain + +Other GTS spec [Reference Implementation](https://github.com/GlobalTypeSystem/gts-spec/blob/main/README.md#9-reference-implementation-recommendations) recommended features support: + +- [x] **In-memory entities registry** - thread-safe GTS entities registry with conditional (conflict-aware) and staged, all-or-nothing batch registration +- [x] **CLI** - command-line interface for all GTS operations +- [x] **Web server** - a non-production web-server with REST API for the operations processing and testing +- [x] **GTS reference keywords** - `x-gts-type-ref`, `x-gts-instance-ref` and `x-gts-pattern-ref` annotations in schemas, with `none` / `any-present` / `any-valid` reference validation modes +- [x] **GTS schema modifiers** - `x-gts-abstract` and `x-gts-final` +- [x] **JSON Schema dialects** - Draft-07, Draft 2019-09 and Draft 2020-12 +- [ ] **YAML support** - to support YAML files (*.yml, *.yaml) as input files +- [ ] **TypeSpec support** - add [typespec.io](https://typespec.io/) files (*.tsp) support +- [x] **UUID for instances** - to support UUID as ID in JSON instances +- [ ] **NuGet packages** - publish the libraries to nuget.org + +## Regular expressions + +`pattern`, `patternProperties`, `propertyNames` and `format: "regex"` (including trait, casting and OP#12 checks) follow the GTS [safe regular-expression profile](https://github.com/GlobalTypeSystem/gts-spec/blob/main/README.md#1101-regular-expression-execution-safety) (spec §11.0.1, ADR-0006). Expressions are first checked against the profile by a dedicated parser (a port of the Rust reference), then executed with .NET `System.Text.RegularExpressions` using `RegexOptions.NonBacktracking`, which guarantees linear-time matching. ## Installation +Requires the [.NET 8 SDK](https://dotnet.microsoft.com/download) or newer (see [`global.json`](global.json)). + +The libraries are not yet published to NuGet. Until they are, build from source and reference the projects directly: + ```bash -#TODO: NuGet packages +git clone https://github.com/GlobalTypeSystem/gts-dotnet.git +cd gts-dotnet +make build # builds the solution and publishes the CLI to ./bin/gts ``` +```xml + + + + + + + +``` + +## Public API + +The solution is split into projects; only the first two are intended as library dependencies: + +| Project | Namespace | Contents | +|---|---|---| +| [`Gts`](Gts) | `Gts`, `Gts.Extraction` | Identifier grammar: `GtsId` (parse, validate, match, UUID), `GtsPattern` (matching and coverage), and `GtsJsonEntity` (ID and reference extraction from JSON). Only depends on [Pidgin](https://www.nuget.org/packages/Pidgin). | +| [`Gts.Store`](Gts.Store) | `Gts.Store` | `GtsRegistry` (in-memory store), instance/schema validation, compatibility checking, casting, queries and attribute access. Uses [JsonSchema.Net](https://www.nuget.org/packages/JsonSchema.Net). | +| [`Gts.Application`](Gts.Application) | `Gts.Application` | Host glue shared by the CLI and server: REST endpoint mapping (`MapGtsApi`), file loading, OpenAPI spec. | +| [`Gts.Cli`](Gts.Cli) | — | The `gts` command-line tool (also hosts the HTTP server). | +| [`Gts.Server`](Gts.Server) | — | Standalone ASP.NET Core host for the REST API. | + +All registry operations are asynchronous (`ValueTask`), accept an optional `CancellationToken`, and report failures through result objects (`Ok` + a typed `GtsValidationFailure` reason) rather than exceptions. + ## Usage ### Library ```csharp -using Gts; -using Gts.Extraction; +using System.Text.Json.Nodes; +using Gts; // GtsId, GtsPattern +using Gts.Extraction; // GtsJsonEntity, GtsExtractOptions +using Gts.Store; // GtsRegistry and result types +``` + +#### OP#1 - ID Validation + +`GtsId.TryParse` returns a `ParseResult` that converts implicitly to `bool`; `GtsPattern.IsValid` also accepts wildcard patterns. + +```csharp +if (GtsId.TryParse("gts.x.core.events.type.v1~", out var id)) + Console.WriteLine(id!.IsType); // True + +bool ok = GtsId.TryParse("gts.x.core.events.type", out _); // false: no version +GtsPattern.IsValid("gts.x.core.events.*"); // true ``` -### OP#2 - ID Extraction +#### OP#2 - ID Extraction -Extract entity and schema IDs from JSON objects (and JSON Schema documents). Uses `System.Text.Json` (`JsonObject` / `JsonNode`). +Extract entity and schema IDs from JSON objects and JSON Schema documents (`System.Text.Json`). The ID is taken from the first matching field in `GtsExtractOptions.EntityIdPropertyNames` (`gtsId`, `id`, `$id`, …) and the schema ID from the ID chain or from `GtsExtractOptions.SchemaIdPropertyNames` (`gtsTid`, `type`, …). -- **ExtractId(JsonObject, GtsExtractOptions?)** — returns `ExtractResult` with `Id`, `SchemaId`, which fields were used, and `IsSchema`. `Id` is null when no valid ID is found. -- **ExtractId(JsonNode?)** / **ExtractId(JsonElement)** — overloads for different JSON sources. -- **ExtractEntity(JsonObject, …)** — returns `GtsJsonEntity` with parsed `GtsId`, schema ID, and all **GtsRefs** (every GTS ID in the tree with path). -- **ExtractReferences(JsonNode?)** — walks the tree and returns all GTS IDs with their JSON paths. -- **GtsExtractOptions.Default** — default entity fields. +- **`GtsJsonEntity.ExtractId(JsonObject | JsonNode | JsonElement, GtsExtractOptions?)`** — returns `ExtractResult` (`Id`, `SchemaId`, `SelectedEntityField`, `SelectedSchemaIdField`, `IsSchema`). +- **`GtsJsonEntity.ExtractEntity(JsonObject, GtsExtractOptions?)`** — returns a `GtsJsonEntity` (parsed `GtsId`, `SchemaId`, `Content`, and every GTS ID in the tree as `GtsRefs`), ready to be saved in a registry. +- **`GtsJsonEntity.ExtractReferences(JsonNode?)`** — every GTS ID in the tree with its JSON path. ```csharp -var node = JsonNode.Parse("""{ "gtsId": "gts.acme.order.ns.invoice.v1.0", "name": "Order 1" }"""); +var json = JsonNode.Parse(""" + { "id": "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0", "name": "Order placed" } + """)!.AsObject(); + +var result = GtsJsonEntity.ExtractId(json); +// result.Id == "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0" +// result.SchemaId == "gts.x.core.events.type.v1~" (derived from the ID chain) + +var entity = GtsJsonEntity.ExtractEntity(json); +// entity.GtsId, entity.SchemaId, entity.IsSchema, entity.GtsRefs +``` -var result = GtsExtract.ExtractId(node.AsObject()); -// result.Id, result.SchemaId, result.SelectedEntityField, result.IsSchema +#### OP#3 - ID Parsing -var entity = GtsExtract.ExtractEntity(node.AsObject()); -// entity.GtsId, entity.GtsRefs (all GTS IDs + paths) +`GtsId.Parse` throws `Gts.Parsing.ParseException` on invalid input; `TryParse` does not. `ParsePattern` / `TryParsePattern` accept a trailing wildcard. -var refs = GtsExtract.ExtractReferences(node); +```csharp +var id = GtsId.Parse("gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~"); +foreach (var s in id.Segments) + Console.WriteLine($"{s.Vendor}.{s.Package}.{s.Namespace}.{s.Type} major={s.VersionMajor} minor={s.VersionMinor}"); +// x.core.events.type major=1 minor= +// x.commerce.orders.order_placed major=1 minor=0 + +var pattern = GtsId.ParsePattern("gts.x.core.events.*"); ``` -### OP#3 - ID Parsing +#### OP#4 - ID Pattern Matching + +`GtsId.Matches` checks a concrete ID against a pattern. `GtsPattern` implements the full §10 semantics, including pattern coverage (`Covers`: every ID selected by the other pattern is also selected by this one). -Decompose GTS identifiers into constituent parts (vendor, package, namespace, type, version, etc.). +```csharp +var candidate = GtsId.Parse("gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~"); +candidate.Matches("gts.x.core.events.type.v1~*"); // true +candidate.Matches("gts.x.core.events.*"); // true +candidate.Matches("gts.y.*"); // false + +GtsPattern.TryParse("gts.x.core.events.type.v1~*", out var all); +GtsPattern.TryParse("gts.x.core.events.type.v1~x.commerce.*", out var commerce); +all!.Matches(candidate); // true +all.Covers(commerce!); // true +commerce!.Covers(all); // false +``` -- **Parse** a type ID (trailing `~`) or instance ID; **TryParse** for safe parsing without exceptions. -- **ParsePattern** / **TryParsePattern** for patterns that may end with a wildcard (`.*`). +#### OP#5 - ID to UUID Mapping + +`ToGuid()` returns a deterministic UUID v5 in the GTS namespace (an instance ID ending in a UUID returns that UUID). ```csharp -// Parsing -var id = GtsId.Parse("gts.acme.order.ns.invoice.v1~"); +Guid uuid = GtsId.Parse("gts.x.core.events.type.v1~").ToGuid(); +``` -// Safe parsing -if (GtsId.TryParse("gts.vendor.pkg.ns.type.v1.0", out var id)) - // do something +#### Using the GTS registry -// Pattern (for matching) -var pattern = GtsId.ParsePattern("gts.acme.order.*"); +`GtsRegistry` stores schemas and instances and runs OP#6–OP#13 over them. Entities are registered as `GtsJsonEntity` values produced by `ExtractEntity`. -// Safe pattern parsing -if (GtsId.TryParsePattern("gts.acme.order.*", out var pattern)) - // do something +```csharp +var registry = GtsRegistry.InMemory(new GtsRegistryConfig(ValidateGtsReferences: false)); + +await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(""" + { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.shop.orders.order.v1.0~", + "type": "object", + "required": ["id", "amount"], + "properties": { + "id": { "type": "string" }, + "amount": { "type": "number", "minimum": 0 } + } + } + """)!.AsObject())); + +await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(""" + { "id": "gts.x.shop.orders.order.v1.0~x.shop._.order_42.v1", "amount": 42 } + """)!.AsObject())); ``` -### OP#4 - ID Pattern Matching +- **`SaveAsync`** stores or overwrites an entity. **`TrySaveAsync`** is an atomic compare-and-swap returning `GtsSaveOutcome.Added`, `Unchanged`, or `Conflict` (same ID, different content). +- **`GetAsync(GtsId)`**, **`GetByInstanceIdAsync(string)`** (GTS ID or opaque ID such as a UUID), **`GetAllAsync`**, **`CountAsync`**. +- **`StageAsync`** / **`CommitStagedAsync`** / **`CommitStagedBatchAsync`** / **`DiscardStagedAsync`** register entities that stay invisible until validated, and publish a batch all-or-nothing. + +`GtsRegistryConfig.ValidateGtsReferences` is reserved and not currently consulted; references are checked by the `Validate*` operations below. -Match identifiers against patterns containing wildcards. +#### OP#6 - Instance Validation -- **Matches(GtsId pattern)** — match this ID against a parsed pattern. -- **Matches(string pattern)** — match this ID against a pattern string. +Resolves the instance's type (the chained ID, or the `type` field for anonymous instances), loads the schema and its `gts://` `$ref` dependencies from the registry, and evaluates the instance, including GTS reference keywords. ```csharp -var candidate = GtsId.Parse("gts.acme.order.ns.invoice.v1.0"); +var r = await registry.ValidateInstanceAsync("gts.x.shop.orders.order.v1.0~x.shop._.order_42.v1"); +// r.Ok, r.FailureReason (e.g. GtsValidationFailure.SchemaValidationFailed), r.SchemaErrors + +// Validate an unregistered JSON document against a registered schema, +// relaxing GTS reference checks to syntax only +var j = await registry.ValidateJsonAsync( + JsonNode.Parse("""{ "id": "order-1", "amount": 1 }""")!.AsObject(), + GtsId.Parse("gts.x.shop.orders.order.v1.0~"), + refValidationMode: GtsRefValidationMode.None); +``` -// Match against pattern (parsed) -var pattern = GtsId.ParsePattern("gts.acme.order.*"); -candidate.Matches(pattern); // true +`GtsRefValidationMode` controls how `x-gts-*-ref` values are checked: `None` (syntax only), `AnyPresent` (the referenced entity must be registered), or `AnyValid` (default; it must also be valid). Use `GtsValidationFailures.ToWire()` to get the string form of a failure reason. -// Match against pattern string -candidate.Matches("gts.acme.order.*"); // true -candidate.Matches("gts.acme.order.ns.*"); // true -candidate.Matches("gts.other.*"); // false +#### OP#7 - Relationship Resolution -// Exact match (no wildcard) -candidate.Matches("gts.acme.order.ns.invoice.v1.0"); // true +```csharp +var rel = await registry.ResolveRelationshipsAsync(); +// rel.IsConsistent, rel.EntityCount, rel.SchemaCount, rel.InstanceCount +foreach (var b in rel.BrokenReferences) + Console.WriteLine($"{b.SourceId} -> {b.ReferencedId} at {b.SourcePath}: {b.Reason}"); ``` -### OP#5 - ID to UUID Mapping +#### OP#8 - Compatibility Checking + +```csharp +var cmp = await registry.CompareMinorVersionSchemasAsync( + GtsId.Parse("gts.x.shop.orders.order.v1.0~"), + GtsId.Parse("gts.x.shop.orders.order.v1.1~")); +// cmp.IsBackwardEvolutionCompatible / BackwardEvolutionErrors +// cmp.IsForwardEvolutionCompatible / ForwardEvolutionErrors +// cmp.IsEvolutionFullyCompatible + +var report = await registry.CheckMinorVersionCompatibilityAsync(); // every minor pair in the registry +// report.AreAllCompatible, report.IncompatiblePairs +``` + +#### OP#9 - Version Casting + +Casts a stored instance to another minor version of its type: fills defaults, updates GTS ID `const` fields, prunes properties when `additionalProperties` is `false`, then validates against the target schema. + +```csharp +var cast = await registry.CastInstanceAsync( + "gts.x.shop.orders.order.v1.0~x.shop._.order_42.v1", + GtsId.Parse("gts.x.shop.orders.order.v1.1~")); +// cast.Ok, cast.CastedContent, cast.Comparison, cast.FailureReason, cast.SchemaValidationErrors +``` -Generate a deterministic UUID v5 from a GTS identifier. The same ID always yields the same UUID (RFC 4122, namespace + name hashed). +#### OP#10 - Query Execution -- **ToGuid()** — returns a `Guid` for this GTS ID using the standard GTS namespace. +ID patterns with optional attribute filters (combined with AND). `limit` accepts 1–1000 (default 100). ```csharp -var id = GtsId.Parse("gts.acme.order.ns.invoice.v1.0"); -Guid uuid = id.ToGuid(); // deterministic: same ID → same UUID every time +var q = await registry.QueryAsync("gts.x.shop.orders.order.v1.0~x.shop.*[amount=42]", limit: 10); +// q.Ok, q.Error, q.Results (matching entity bodies) ``` -### OP#6 - Instance Validation +#### OP#11 - Attribute Access -Validate stored JSON instances against Draft 07 JSON Schemas registered in the same `GtsRegistry`, including `gts://` `$ref` between schemas and GTS `$$id` / `$$ref` / `$$schema` keywords on schema documents. +```csharp +var attr = await registry.GetAttributeAsync("gts.x.shop.orders.order.v1.0~x.shop._.order_42.v1@amount"); +// attr.Resolved, attr.Value (JsonNode), attr.Error, attr.AvailableFields +``` -- **ValidateInstanceAsync(instanceId)** — loads the instance (by GTS instance id or opaque id such as a UUID), resolves its type (chained instance id or `type` field), fetches the schema from the store, and evaluates the instance with [JsonSchema.Net](https://www.nuget.org/packages/JsonSchema.Net). Returns `GtsInstanceValidationResult` with `Ok`, `Id`, `FailureReason`, and optional `SchemaErrors`. +#### OP#12 / OP#13 - Schema and Traits Validation + +Validates a schema's dialect, GTS keywords, `$ref` format and regular expressions, checks that it is compatible with every precedent type in its ID chain, and validates `x-gts-traits-schema` / `x-gts-traits` across the chain. ```csharp -using Gts.Extraction; -using Gts.Store; +var sv = await registry.ValidateSchemaAsync("gts.x.shop.orders.order.v1.0~x.shop.orders.priority_order.v1~"); +// sv.Ok, sv.FailureReason (e.g. PrecedentIncompatible, TraitValidationFailed), sv.Errors -var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); -// Save schemas (JSON Schema with $id or $$id) and instances via SaveAsync(GtsJsonEntity.ExtractEntity(...)) -var result = await registry.ValidateInstanceAsync("gts.vendor.pkg.ns.type.v1~x._.myinst.v1"); -// result.Ok, result.FailureReason, result.SchemaErrors +// Or validate an unregistered schema document against the registered precedents +JsonObject schemaDocument = /* a GTS Type Schema */; +var doc = await registry.ValidateSchemaAsync( + GtsId.Parse("gts.x.shop.orders.order.v1.0~x.shop.orders.priority_order.v1~"), schemaDocument); ``` +### CLI + +Build the CLI with `make build`; the binary is written to `./bin/gts`. Global options go before the command, and every command prints JSON to stdout. + +```bash +# Show help / version +gts help +gts version + +# Basic operations (no file loading required) +gts validate-id -id 'gts.x.shop.orders.order.v1~' # OP#1 +gts parse-id -id 'gts.x.shop.orders.order.v1~' # OP#3 +gts match-id-pattern -pattern 'gts.x.shop.*' -candidate 'gts.x.shop.orders.order.v1~' # OP#4 +gts uuid -id 'gts.x.shop.orders.order.v1~' # OP#5 + +# Operations with loaded entities. +# Placeholders: point -path at your own schema/instance files or directories +# and substitute their IDs. +gts -path ./examples validate -id 'gts.x.shop.orders.order.v1.0~x.shop._.order_42.v1' # OP#6 +gts -path ./examples relationships -id 'gts.x.shop.orders.order.v1.0~' # OP#7 +gts -path ./examples compatibility -old 'gts.x.shop.orders.order.v1.0~' -new 'gts.x.shop.orders.order.v1.1~' # OP#8 +gts -path ./examples cast -from 'gts.x.shop.orders.order.v1.0~x.shop._.order_42.v1' -to 'gts.x.shop.orders.order.v1.1~' # OP#9 +gts -path ./examples query -expr 'gts.x.shop.*' -limit 10 # OP#10 +gts -path ./examples attr -path 'gts.x.shop.orders.order.v1.0~x.shop._.order_42.v1@amount' # OP#11 +gts -path ./examples list -limit 100 + +# Start the HTTP server / write the OpenAPI specification +gts -path ./examples server -host 127.0.0.1 -port 8000 +gts openapi -out openapi.json +``` + +Global options: + +| Option | Environment variable | Description | +|---|---|---| +| `-path ` | `GTS_PATH` | Comma-separated files or directories to load (`.json`, `.jsonc`, `.gts`; a file may hold one entity or an array) | +| `-config ` | `GTS_CONFIG` | Optional JSON config with `entity_id_fields` / `schema_id_fields` arrays overriding the extraction field names | +| `-v`, `--verbose`, `-v2` | `GTS_VERBOSE` | Verbose logging | + +### Web server + +A non-production HTTP server exposes the operations over REST, using the API exercised by the gts-spec conformance suite. The in-memory registry starts empty unless `-path` is given. + +```bash +gts server # http://127.0.0.1:8000 +gts server -host 0.0.0.0 -port 8001 +gts -path ./examples server # pre-populate from files + +# or the standalone host (listens on ASPNETCORE_URLS, default http://127.0.0.1:8000) +dotnet run --project Gts.Server +``` + +To embed the API in your own ASP.NET Core app, call `app.MapGtsApi(registry)` from `Gts.Application`. The full request/response schema is served at `GET /openapi`. + +#### Entity management + +| Method | Path | Description | +|---|---|---| +| `GET` | `/entities?limit=` | List registered entities | +| `GET` | `/entities/{id}` | Get an entity by GTS ID or opaque ID | +| `POST` | `/entities` | Register one entity; `?validate=true` validates before publishing, `?gts-ref-validation=none\|any-present\|any-valid` | +| `POST` | `/entities/bulk` | Register an array of entities | +| `POST` | `/type-schemas` | Register an array of type schemas; with `?validate=true` the batch is validated order-independently and published atomically | + +#### GTS operations + +| Method | Path | Operation | +|---|---|---| +| `GET` | `/validate-id?gts_id=` | OP#1 - ID Validation | +| `POST` | `/extract-id` | OP#2 - ID Extraction | +| `GET` | `/parse-id?gts_id=` | OP#3 - ID Parsing | +| `GET` | `/match-id-pattern?candidate=&pattern=` | OP#4 - ID Pattern Matching | +| `GET` | `/uuid?gts_id=` | OP#5 - ID to UUID Mapping | +| `POST` | `/validate-instance` | OP#6 - Validate a registered instance (`{"instance_id": "..."}`) | +| `POST` | `/validate-json`, `/validate-json/{type}` | OP#6 - Validate an unregistered JSON document | +| `GET` | `/resolve-relationships?gts_id=` | OP#7 - Relationship Resolution | +| `GET` | `/compatibility?old_type_id=&new_type_id=` | OP#8 - Compatibility Checking | +| `POST` | `/cast` | OP#9 - Version Casting | +| `GET` | `/query?expr=&limit=` | OP#10 - Query Execution | +| `GET` | `/attr?gts_with_path=` | OP#11 - Attribute Access | +| `POST` | `/validate-type-schema`, `/validate-schema` | OP#12/OP#13 - Schema and Traits Validation (`{"type_id": "..."}`) | +| `POST` | `/validate-entity` | Validate any registered entity, schema or instance (`{"entity_id": "..."}`) | +| `GET` | `/openapi` | OpenAPI specification | + ## Development Common tasks are wrapped in the [`Makefile`](Makefile); run `make help` to @@ -143,6 +369,7 @@ list every target. Requires the .NET SDK (see [`global.json`](global.json)). ```bash make build # build the solution and publish the gts CLI to ./bin/gts +make dev-fmt # apply formatting fixes make fmt # verify formatting (dotnet format whitespace + style) make lint # run analyzers (dotnet format analyzers) make test # run the unit tests @@ -172,8 +399,8 @@ make gts-spec-tests TEST=test_op1_id_validation.py # single file / selector Opt into the rolling minor tag, try a different patch, or test a fork: ```bash -make gts-spec-tests GTS_SPEC_VERSION=v0.11 # rolling vMAJOR.MINOR -make gts-spec-tests GTS_SPEC_VERSION=v0.11.0 # specific patch +make gts-spec-tests GTS_SPEC_VERSION=v0.17 # rolling vMAJOR.MINOR +make gts-spec-tests GTS_SPEC_VERSION=v0.17.0 # specific patch make gts-spec-tests GTS_SPEC_IMAGE=ghcr.io/your-fork/gts-spec-tests ``` @@ -191,10 +418,9 @@ re-run targeted tests in another: make gts-server PORT=8001 # Terminal 2 -make gts-spec-tests-run PORT=8001 TEST=test_op6_instance_validation.py +make gts-spec-tests-run PORT=8001 TEST=test_op6_schema_validation.py ``` ## License Apache License 2.0 -