diff --git a/.gts-spec-version b/.gts-spec-version index 6cf9fa5..574be60 100644 --- a/.gts-spec-version +++ b/.gts-spec-version @@ -1 +1 @@ -v0.14.4 +v0.14.5 diff --git a/Gts.Application/GtsHttpApiHelpers.cs b/Gts.Application/GtsHttpApiHelpers.cs index 389b1d1..f5d8b4c 100644 --- a/Gts.Application/GtsHttpApiHelpers.cs +++ b/Gts.Application/GtsHttpApiHelpers.cs @@ -15,12 +15,11 @@ internal static int EnumConstraintChange(JsonObject oldSchema, JsonObject newSch return 0; } - internal static string NormalizeDialect(string? dialect) + internal static string NormalizeDialect(string? declared) { - if (dialect?.Contains("draft-07", StringComparison.Ordinal) == true) return "draft-07"; - if (dialect?.Contains("2019-09", StringComparison.Ordinal) == true) return "2019-09"; - if (dialect?.Contains("2020-12", StringComparison.Ordinal) == true) return "2020-12"; - return dialect?.TrimEnd('#') ?? "draft-07"; + return GtsTypeSchema.TryNormalizeSupportedDialect(declared, out var dialect, out _) + ? dialect + : "unsupported:" + (declared ?? ""); } internal static bool ContainsSchemaKeyword(JsonNode? node, string keyword) => node switch diff --git a/Gts.Store/GtsInstanceValidationService.cs b/Gts.Store/GtsInstanceValidationService.cs index 5914469..3f4e5bc 100644 --- a/Gts.Store/GtsInstanceValidationService.cs +++ b/Gts.Store/GtsInstanceValidationService.cs @@ -92,9 +92,9 @@ internal async ValueTask ValidateAsync( { // An untrusted schema `pattern` whose match exceeds the engine's // bounded time budget is reported as a validation failure rather - // than propagating a hang/exception. The JsonSchema.Net engine - // bounds match time; this mirrors the "regular expression match - // timed out" outcome the sibling runtimes surface (gts-go/gts-python). + // than propagating a hang/exception. The bounded regex preflight + // mirrors the "regular expression match timed out" outcome the + // sibling runtimes surface (gts-go/gts-python). return new GtsInstanceValidationResult { Ok = false, @@ -103,6 +103,16 @@ internal async ValueTask ValidateAsync( SchemaErrors = new[] { "regular expression match timed out" } }; } + catch (RegexParseException exception) + { + return new GtsInstanceValidationResult + { + Ok = false, + Id = instanceId, + FailureReason = GtsValidationFailure.SchemaValidationFailed, + SchemaErrors = new[] { $"invalid regular expression: {exception.Message}" } + }; + } } private static GtsInstanceValidationResult Failure(string? id, GtsValidationFailure? reason) => new() { Ok = false, Id = id, FailureReason = reason }; diff --git a/Gts.Store/GtsSchemaDependencyGraph.cs b/Gts.Store/GtsSchemaDependencyGraph.cs index b5df793..480c7f5 100644 --- a/Gts.Store/GtsSchemaDependencyGraph.cs +++ b/Gts.Store/GtsSchemaDependencyGraph.cs @@ -7,11 +7,17 @@ internal static class GtsSchemaDependencyGraph { internal static string Dialect(JsonObject schema) { - var value = schema["$schema"]?.GetValue() ?? "http://json-schema.org/draft-07/schema#"; - if (value.Contains("draft-07", StringComparison.Ordinal)) return "draft-07"; - if (value.Contains("2019-09", StringComparison.Ordinal)) return "2019-09"; - if (value.Contains("2020-12", StringComparison.Ordinal)) return "2020-12"; - return value.TrimEnd('#'); + var document = !schema.ContainsKey("$schema") && schema.ContainsKey("$$schema") + ? GtsSchemaDocumentNormalizer.CanonicalizeKeywords(schema) + : schema; + // An unsupported or missing dialect is a validation concern, not an internal fault: the + // callers (ValidateDialects, HasMixedDialectReferences) compare dialects to detect a + // cross-dialect graph and report failures via their result/error channels. Returning a + // sentinel that can never equal a supported dialect keeps an unsupported referenced schema + // a reported mismatch instead of an exception that escapes the validator as an internal error. + return GtsTypeSchema.TryGetSupportedDialect(document, out var dialect, out _) + ? dialect + : "unsupported:" + (document["$schema"]?.ToJsonString() ?? ""); } internal static JsonObject Resolve(JsonObject schema, Func load) => Resolve(schema, load, new HashSet(), 0); diff --git a/Gts.Store/GtsSchemaKeywordValidator.cs b/Gts.Store/GtsSchemaKeywordValidator.cs index 410288e..2f14885 100644 --- a/Gts.Store/GtsSchemaKeywordValidator.cs +++ b/Gts.Store/GtsSchemaKeywordValidator.cs @@ -1,4 +1,5 @@ using System.Text.Json.Nodes; +using Gts.Store.Validation; namespace Gts.Store; @@ -12,7 +13,7 @@ public static class GtsSchemaKeywordValidator public static IReadOnlyList Validate(JsonObject schema) { var errors = new List(); - ValidateNode(schema, true, errors); + GtsSchemaWalker.Visit(schema, (node, root) => ValidateNode(node, root, errors)); 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 _)) @@ -38,41 +39,6 @@ private static void ValidateNode(JsonObject schema, bool root, List erro if (!root && TopLevel.Contains(key)) errors.Add($"{key} must be at the schema top level"); } - - RecurseMap(schema["properties"], errors); - RecurseMap(schema["patternProperties"], errors); - RecurseMap(schema["definitions"], errors); - RecurseMap(schema["$defs"], errors); - RecurseMap(schema["dependentSchemas"], errors); - foreach (var key in new[] { "additionalProperties", "additionalItems", "contains", "not", "if", "then", "else", "propertyNames", "unevaluatedProperties", "unevaluatedItems" }) - Recurse(schema[key], errors); - foreach (var key in new[] { "allOf", "anyOf", "oneOf", "prefixItems" }) - RecurseArray(schema[key], errors); - Recurse(schema["items"], errors); - } - - private static void RecurseMap(JsonNode? node, List errors) - { - if (node is not JsonObject map) - return; - foreach (var child in map.Select(property => property.Value).OfType()) - ValidateNode(child, false, errors); - } - - private static void RecurseArray(JsonNode? node, List errors) - { - if (node is not JsonArray array) - return; - foreach (var child in array.OfType()) - ValidateNode(child, false, errors); - } - - private static void Recurse(JsonNode? node, List errors) - { - if (node is JsonObject child) - ValidateNode(child, false, errors); - else - RecurseArray(node, errors); } private static bool TryBoolean(JsonNode? node, out bool value) diff --git a/Gts.Store/GtsSchemaTraitsValidator.cs b/Gts.Store/GtsSchemaTraitsValidator.cs index d55a1ba..12d7e99 100644 --- a/Gts.Store/GtsSchemaTraitsValidator.cs +++ b/Gts.Store/GtsSchemaTraitsValidator.cs @@ -260,15 +260,11 @@ private static void ValidateConstValues(JsonNode? schema, JsonNode? values, stri } } - private static string NormalizeDialect(string? dialect) + private static string NormalizeDialect(string? declared) { - if (dialect?.Contains("draft-07", StringComparison.Ordinal) == true) - return "draft-07"; - if (dialect?.Contains("2019-09", StringComparison.Ordinal) == true) - return "2019-09"; - if (dialect?.Contains("2020-12", StringComparison.Ordinal) == true) - return "2020-12"; - return dialect?.TrimEnd('#') ?? "draft-07"; + return GtsTypeSchema.TryNormalizeSupportedDialect(declared, out var dialect, out _) + ? dialect + : "unsupported:" + (declared ?? ""); } private static bool ContainsGtsRef(JsonNode? node) => node switch diff --git a/Gts.Store/GtsSchemaValidationService.cs b/Gts.Store/GtsSchemaValidationService.cs index 0da7a1f..818c81e 100644 --- a/Gts.Store/GtsSchemaValidationService.cs +++ b/Gts.Store/GtsSchemaValidationService.cs @@ -1,6 +1,7 @@ using System.Text.Json.Nodes; using Gts.Extraction; using Gts.Store.Validation; +using Json.Schema; namespace Gts.Store; @@ -62,6 +63,14 @@ internal async ValueTask ValidateAsync(GtsId schemaId if (candidate.IsSchema && candidate.GtsId is not null) schemaById[candidate.GtsId.Id] = candidate; } + try + { + GtsJsonSchemaEvaluator.ValidateSchema(GtsSchemaDocumentNormalizer.ForJsonSchemaEvaluation(document)); + } + catch (Exception exception) when (exception is JsonSchemaException or ArgumentException) + { + return new GtsSchemaValidationResult { Ok = false, SchemaId = schemaId.Id, FailureReason = GtsValidationFailure.InvalidJsonSchema, Errors = new[] { exception.Message } }; + } JsonObject? Load(GtsId id) { diff --git a/Gts.Store/GtsTypeSchema.cs b/Gts.Store/GtsTypeSchema.cs index 0620ee9..9e32ccf 100644 --- a/Gts.Store/GtsTypeSchema.cs +++ b/Gts.Store/GtsTypeSchema.cs @@ -32,6 +32,19 @@ public static bool TryGetSupportedDialect(JsonObject schema, out string dialect, return false; } + return TryNormalizeSupportedDialect(declared, out dialect, out error); + } + + public static bool TryNormalizeSupportedDialect(string? declared, out string dialect, out string? error) + { + dialect = ""; + error = null; + if (string.IsNullOrWhiteSpace(declared)) + { + error = "$schema must declare a supported JSON Schema dialect"; + return false; + } + var normalized = declared.Replace("https://", "http://", StringComparison.Ordinal).TrimEnd('#'); switch (normalized) { diff --git a/Gts.Store/Validation/GtsFormatRegistry.cs b/Gts.Store/Validation/GtsFormatRegistry.cs index ba9fe77..61ec856 100644 --- a/Gts.Store/Validation/GtsFormatRegistry.cs +++ b/Gts.Store/Validation/GtsFormatRegistry.cs @@ -55,10 +55,10 @@ private static bool IsRegex(string value) { try { - _ = new Regex(value, RegexOptions.ECMAScript); + _ = new Regex(value, RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(250)); return true; } - catch + catch (ArgumentException) { return false; } diff --git a/Gts.Store/Validation/GtsJsonSchemaEngine.cs b/Gts.Store/Validation/GtsJsonSchemaEngine.cs index 0e6af27..fa60cd7 100644 --- a/Gts.Store/Validation/GtsJsonSchemaEngine.cs +++ b/Gts.Store/Validation/GtsJsonSchemaEngine.cs @@ -10,6 +10,9 @@ internal sealed class GtsJsonSchemaEngine : IGtsJsonSchemaEngine { internal static GtsJsonSchemaEngine Default { get; } = new(); + private static readonly Dialect Draft07 = GtsRegexDialect.WithBoundedPatterns(Dialect.Draft07); + private static readonly Dialect Draft201909 = GtsRegexDialect.WithBoundedPatterns(Dialect.Draft201909); + private static readonly Dialect Draft202012 = GtsRegexDialect.WithBoundedPatterns(Dialect.Draft202012); private readonly ConcurrentDictionary _compiled = new(StringComparer.Ordinal); private readonly FormatRegistry _formats = GtsFormatRegistry.Create(); @@ -37,10 +40,26 @@ public EvaluationResults EvaluateInline(JsonNode? instance, JsonObject schemaDoc { var normalized = GtsSchemaDocumentNormalizer.ForJsonSchemaEvaluation(schemaDocument); var fingerprint = Fingerprint("inline", new[] { normalized }); - var schema = GetOrCompile(fingerprint, () => JsonSchema.FromText(normalized.ToJsonString(), BuildOptions(normalized, new SchemaRegistry()))); + var schema = GetOrCompile(fingerprint, () => JsonSchema.FromText(WithoutDialectDeclaration(normalized).ToJsonString(), BuildOptions(normalized, new SchemaRegistry()))); return schema.Evaluate(GtsJson.ToElement(instance), EvaluationOptions()); } + public void ValidateSchema(JsonObject normalizedSchemaDocument) + { + if (!GtsTypeSchema.TryGetSupportedDialect(normalizedSchemaDocument, out var dialect, out var error)) + throw new JsonSchemaException(error!); + var metaSchema = dialect switch + { + "draft-07" => MetaSchemas.Draft7, + "2019-09" => MetaSchemas.Draft201909, + "2020-12" => MetaSchemas.Draft202012, + _ => throw new JsonSchemaException($"Unsupported JSON Schema dialect: {dialect}") + }; + var result = metaSchema.Evaluate(GtsJson.ToElement(normalizedSchemaDocument), EvaluationOptions()); + if (!result.IsValid) + throw new JsonSchemaException("JSON Schema validation failed: " + string.Join("; ", FlattenErrors(result))); + } + public IReadOnlyList FlattenErrors(EvaluationResults results) { var errors = new List(); @@ -63,9 +82,9 @@ private JsonSchema Compile(GtsId rootId, JsonObject root, IReadOnlyDictionary new() @@ -83,14 +102,24 @@ private JsonSchema Compile(GtsId rootId, JsonObject root, IReadOnlyDictionary(out var s) ? s : null; - if (uri?.Contains("2020-12", StringComparison.Ordinal) == true) - return Dialect.Draft202012; - if (uri?.Contains("2019-09", StringComparison.Ordinal) == true) - return Dialect.Draft201909; - return Dialect.Draft07; + if (!GtsTypeSchema.TryGetSupportedDialect(schema, out var dialect, out var error)) + throw new JsonSchemaException(error!); + return dialect switch + { + "draft-07" => Draft07, + "2019-09" => Draft201909, + "2020-12" => Draft202012, + _ => throw new JsonSchemaException($"Unsupported JSON Schema dialect: {dialect}") + }; } private static string Fingerprint(string root, IEnumerable schemas) diff --git a/Gts.Store/Validation/GtsJsonSchemaEvaluator.cs b/Gts.Store/Validation/GtsJsonSchemaEvaluator.cs index bc26b3d..40fd1fb 100644 --- a/Gts.Store/Validation/GtsJsonSchemaEvaluator.cs +++ b/Gts.Store/Validation/GtsJsonSchemaEvaluator.cs @@ -16,5 +16,8 @@ internal static EvaluationResults Evaluate( internal static EvaluationResults EvaluateInline(JsonNode? instance, JsonObject schemaDocument) => Engine.EvaluateInline(instance, schemaDocument); + internal static void ValidateSchema(JsonObject normalizedSchemaDocument) => + Engine.ValidateSchema(normalizedSchemaDocument); + internal static IReadOnlyList FlattenErrors(EvaluationResults results) => Engine.FlattenErrors(results); } \ No newline at end of file diff --git a/Gts.Store/Validation/GtsRegexKeywords.cs b/Gts.Store/Validation/GtsRegexKeywords.cs new file mode 100644 index 0000000..3ba8861 --- /dev/null +++ b/Gts.Store/Validation/GtsRegexKeywords.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Json.Schema; +using Json.Schema.Keywords; + +namespace Gts.Store.Validation; + +internal sealed class GtsPatternKeyword : PatternKeyword +{ + internal static GtsPatternKeyword Handler { get; } = new(); + + private GtsPatternKeyword() { } + + public override object? ValidateKeywordValue(JsonElement value) + { + 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)); + } +} + +internal sealed class GtsPatternPropertiesKeyword : PatternPropertiesKeyword +{ + internal static GtsPatternPropertiesKeyword Handler { get; } = new(); + + private GtsPatternPropertiesKeyword() { } + + public override object? ValidateKeywordValue(JsonElement value) + { + if (value.ValueKind is not JsonValueKind.Object) + throw new JsonSchemaException($"'{Name}' value must be an object, found {value.ValueKind}"); + + var regexes = new Dictionary(); + foreach (var property in value.EnumerateObject()) + { + 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))); + } + return regexes; + } +} + +internal static class GtsRegexDialect +{ + internal static Dialect WithBoundedPatterns(Dialect dialect) => dialect + .With([GtsPatternKeyword.Handler, GtsPatternPropertiesKeyword.Handler]); +} diff --git a/Gts.Store/Validation/GtsSchemaDocumentNormalizer.cs b/Gts.Store/Validation/GtsSchemaDocumentNormalizer.cs index 4548f94..ae4ecfe 100644 --- a/Gts.Store/Validation/GtsSchemaDocumentNormalizer.cs +++ b/Gts.Store/Validation/GtsSchemaDocumentNormalizer.cs @@ -17,6 +17,9 @@ internal static JsonObject CanonicalizeKeywords(JsonObject root) return clone; } + internal static void RemoveDialectDeclarations(JsonObject root) => + GtsSchemaWalker.Visit(root, static (schema, _) => schema.Remove("$schema"), includeTraitsSchema: true); + internal static JsonObject ForJsonSchemaEvaluation(JsonObject root) { var clone = (JsonObject)root.DeepClone(); @@ -81,14 +84,9 @@ private static void StripXGtsRefDeep(JsonNode? node) // structural schema, so every value matches both and oneOf rejects everything. // Evaluate such a oneOf as anyOf: exclusivity is enforced by GtsRefValidator. // - // NOTE: gts-go and gts-rust avoid this rewrite by registering x-gts-ref as a real - // JSON Schema keyword/vocabulary, so the engine evaluates it during combinator - // resolution and oneOf "just works". JsonSchema.Net (this project's engine) exposes - // no public API to add a keyword to a built-in dialect — Dialect can only be built - // from a full IKeywordHandler list, and the built-in Draft-07/2019-09/2020-12 sets - // are not publicly enumerable — so we cannot follow that approach without reflecting - // into library internals. This localized normalization is the pragmatic alternative; - // gts-python and gts-ts use the keyword-registration approach their libraries support. + // 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)); foreach (var child in branches) StripXGtsRefDeep(child); diff --git a/Gts.Store/Validation/GtsSchemaWalker.cs b/Gts.Store/Validation/GtsSchemaWalker.cs new file mode 100644 index 0000000..b84148e --- /dev/null +++ b/Gts.Store/Validation/GtsSchemaWalker.cs @@ -0,0 +1,56 @@ +using System.Text.Json.Nodes; + +namespace Gts.Store.Validation; + +/// +/// Visits JSON Schema positions while excluding literal annotation data such as const, +/// default, and examples. JsonSchema.Net does not expose its raw schema-node +/// traversal before dialect selection, nor does it allow replacing an official dialect's +/// handlers. GTS needs this traversal both to enforce placement rules for x-gts-* +/// keywords and to remove already-validated nested $schema declarations from the +/// evaluation clone so every resource keeps the bounded GTS regex handlers. Keeping that +/// knowledge here avoids multiple feature-specific walkers drifting apart. +/// +internal static class GtsSchemaWalker +{ + private static readonly string[] SchemaMaps = ["properties", "patternProperties", "definitions", "$defs", "dependentSchemas"]; + private static readonly string[] SchemaArrays = ["allOf", "anyOf", "oneOf", "prefixItems"]; + private static readonly string[] SingleSchemas = ["additionalItems", "additionalProperties", "contains", "contentSchema", "else", "if", "not", "propertyNames", "then", "unevaluatedItems", "unevaluatedProperties"]; + + internal static void Visit(JsonNode? node, Action visitor, bool includeTraitsSchema = false) => + Visit(node, visitor, true, includeTraitsSchema); + + private static void Visit(JsonNode? node, Action visitor, bool root, bool includeTraitsSchema) + { + if (node is not JsonObject schema) + return; + + visitor(schema, root); + + foreach (var keyword in SchemaMaps) + if (schema[keyword] is JsonObject map) + foreach (var child in map.Select(property => property.Value)) + Visit(child, visitor, false, includeTraitsSchema); + + foreach (var keyword in SchemaArrays) + if (schema[keyword] is JsonArray array) + foreach (var child in array) + Visit(child, visitor, false, includeTraitsSchema); + + if (schema["items"] is JsonArray tupleItems) + foreach (var child in tupleItems) + Visit(child, visitor, false, includeTraitsSchema); + else + Visit(schema["items"], visitor, false, includeTraitsSchema); + + foreach (var keyword in SingleSchemas) + Visit(schema[keyword], visitor, false, includeTraitsSchema); + + if (schema["dependencies"] is JsonObject dependencies) + foreach (var child in dependencies.Select(property => property.Value).OfType()) + Visit(child, visitor, false, includeTraitsSchema); + + if (includeTraitsSchema) + Visit(schema[GtsSchemaKeywords.TraitsSchema], visitor, false, true); + } +} diff --git a/Gts.Store/Validation/IGtsJsonSchemaEngine.cs b/Gts.Store/Validation/IGtsJsonSchemaEngine.cs index 8644f78..908e888 100644 --- a/Gts.Store/Validation/IGtsJsonSchemaEngine.cs +++ b/Gts.Store/Validation/IGtsJsonSchemaEngine.cs @@ -9,5 +9,7 @@ internal interface IGtsJsonSchemaEngine EvaluationResults EvaluateInline(JsonNode? instance, JsonObject schemaDocument); + void ValidateSchema(JsonObject normalizedSchemaDocument); + IReadOnlyList FlattenErrors(EvaluationResults results); } \ No newline at end of file diff --git a/Gts.Tests/Validation/InstanceValidationTests.cs b/Gts.Tests/Validation/InstanceValidationTests.cs index ae5da24..33e3537 100644 --- a/Gts.Tests/Validation/InstanceValidationTests.cs +++ b/Gts.Tests/Validation/InstanceValidationTests.cs @@ -310,9 +310,9 @@ public async Task Catastrophic_pattern_is_bounded_and_does_not_hang() { // An untrusted schema `pattern` that is a classic catastrophic- // backtracking regex, matched against an adversarial instance value, - // must not hang: JsonSchema.Net bounds regex match time, and the - // timeout is surfaced as a validation failure ("regular expression - // match timed out") rather than propagating - the same class of + // must not hang: the bounded regex preflight stops it before the + // JsonSchema.Net evaluation, surfacing "regular expression match timed + // out" as a validation failure rather than propagating - the same // protection gts-go and gts-python get from a match timeout. const string baseSchema = """ { @@ -359,8 +359,9 @@ public async Task Catastrophic_pattern_is_bounded_and_does_not_hang() // The point is that validation *returns* (bounded), rather than pinning // a CPU forever. The adversarial value fails the pattern, so Ok is false. Assert.False(result.Ok); + Assert.Contains("regular expression match timed out", result.SchemaErrors ?? []); Assert.True( - stopwatch.Elapsed < TimeSpan.FromSeconds(60), + 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 fa480b8..ddc59a7 100644 --- a/Gts.Tests/Validation/JsonInfrastructureTests.cs +++ b/Gts.Tests/Validation/JsonInfrastructureTests.cs @@ -1,5 +1,7 @@ using System.Text.Json.Nodes; +using System.Text.RegularExpressions; using Gts.Store.Validation; +using Json.Schema; namespace Gts.Tests.Validation; @@ -34,6 +36,107 @@ public void Schema_engine_uses_strict_registered_uuid_format() Assert.False(invalid.IsValid); } + [Fact] + public void Schema_engine_ignores_pattern_keys_in_annotations() + { + var schema = JsonNode.Parse(""" + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "examples": [{ "pattern": "[" }] + } + """)!.AsObject(); + + var result = GtsJsonSchemaEngine.Default.EvaluateInline(new JsonObject(), schema); + + Assert.True(result.IsValid); + } + + [Fact] + public void Schema_engine_only_matches_patterns_at_their_instance_location() + { + var schema = JsonNode.Parse(""" + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "code": { "type": "string", "pattern": "(a+)+$" } + }, + "additionalProperties": true + } + """)!.AsObject(); + var instance = new JsonObject + { + ["code"] = "a", + ["description"] = new string('a', 40_000) + "!" + }; + + var result = GtsJsonSchemaEngine.Default.EvaluateInline(instance, schema); + + Assert.True(result.IsValid); + } + + [Fact] + public void Schema_engine_bounds_patterns_in_nested_resources() + { + var schema = JsonNode.Parse(""" + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "code": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "string", + "pattern": "(a+)+$" + } + } + } + """)!.AsObject(); + var instance = new JsonObject { ["code"] = new string('a', 40_000) + "!" }; + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + Assert.Throws(() => GtsJsonSchemaEngine.Default.EvaluateInline(instance, schema)); + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(2)); + } + + [Fact] + public void Schema_engine_validation_builds_standard_schema_locations() + { + var schema = JsonNode.Parse(""" + { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.regex._.invalid.v1~", + "definitions": { + "invalid": { "type": "string", "pattern": "[" } + }, + "examples": [{ "pattern": "[" }] + } + """)!.AsObject(); + + Assert.Throws(() => GtsJsonSchemaEngine.Default.ValidateSchema( + GtsSchemaDocumentNormalizer.ForJsonSchemaEvaluation(schema))); + } + + [Fact] + public void Schema_engine_rejects_a_missing_dialect() + { + var schema = JsonNode.Parse("""{ "type": "object" }""")!.AsObject(); + + Assert.Throws(() => GtsJsonSchemaEngine.Default.EvaluateInline(new JsonObject(), schema)); + } + + [Theory] + [InlineData("http://json-schema.org/draft-06/schema#")] + [InlineData("https://json-schema.org/draft/2025-01/schema")] + [InlineData("https://json-schema.org/draft/2020-21/schema")] + [InlineData("https://example.invalid/schema")] + public void Schema_engine_rejects_an_unsupported_dialect(string dialect) + { + var schema = new JsonObject { ["$schema"] = dialect, ["type"] = "object" }; + + Assert.Throws(() => GtsJsonSchemaEngine.Default.EvaluateInline(new JsonObject(), schema)); + } + [Theory] [InlineData("gts.x.pkg.ns.type.v1~")] [InlineData("gts.x.pkg.ns.type.v1~123e4567-e89b-42d3-a456-426614174000")] diff --git a/README.md b/README.md index 3fbc107..2734c58 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 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. -Supported GTS spec version: `v0.14.4` (pinned in [`.gts-spec-version`](.gts-spec-version)) +Supported GTS spec version: `v0.14.5` (pinned in [`.gts-spec-version`](.gts-spec-version)) ## Roadmap