#nullable enable using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using System.Text.RegularExpressions; namespace GameCult.Networking.Tests { /// /// A deliberately small JSON Schema (draft 2020-12) validator covering only the constructs /// contracts/cultnet/*.schema.json actually use: object/array/string/integer/boolean/null types /// (plain or as a ["type","null"] union), required, additionalProperties: false, properties, /// items, const, enum, pattern, minLength, maxLength, minItems, maxItems, minimum, maximum, oneOf, /// not, and $ref - same-document ("#/$defs/name"), cross-document ("other.schema.json"), and both /// ("other.schema.json#/$defs/name"). Not a general validator: it exists for R-S /// (docs/cultnet-selection-cut.md, fix batch 3) to decode real C# wire bytes and check them against /// the committed schema files, which no dependency in this repo already does - see /// CultNetSchemaContractTests. /// R-X (fix batch 4): a schema keyword this validator does not implement is refused, not silently /// ignored - see / and /// . Before this, a schema using e.g. allOf/anyOf/if-then-else/ /// uniqueItems validated an instance as if that keyword were not there at all, which is silent /// under-validation, not the "no such constraint" the schema author intended. /// internal sealed class MiniJsonSchemaValidator { // Keywords this validator gives real constraint semantics to. private static readonly HashSet ImplementedKeywords = new(StringComparer.Ordinal) { "$ref", "oneOf", "not", "const", "enum", "type", "minLength", "maxLength", "pattern", "minItems", "maxItems", "minimum", "maximum", "items", "required", "properties", "additionalProperties" }; // Keywords that are pure annotation/metadata - present on many schema nodes, never checked // against an instance by any JSON Schema vocabulary this validator implements, so there is // nothing for CheckSupportedKeywords to refuse. contentEncoding/contentMediaType/contentSchema // are "media" annotations that draft 2020-12 itself does not assign validation behavior to // without an explicit vocabulary; x-cult-runtime-type is this repo's own vendor extension. private static readonly HashSet MetadataKeywords = new(StringComparer.Ordinal) { "$schema", "$id", "$defs", "title", "description", "default", "contentEncoding", "x-cult-runtime-type" }; private readonly string _schemaDir; private readonly Dictionary _docCache = new(StringComparer.Ordinal); public MiniJsonSchemaValidator(string schemaDir) { _schemaDir = schemaDir; } /// Validates against the named schema file's root schema. Throws with every collected error when invalid. public void AssertValid(JsonElement instance, string schemaFileName) { var errors = Validate(instance, schemaFileName); if (errors.Count > 0) { throw new InvalidOperationException( $"{instance} does not satisfy {schemaFileName}:{Environment.NewLine}{string.Join(Environment.NewLine, errors)}"); } } /// Validates against a $defs entry of the named schema file, e.g. "cultnet.selection.schema.json#/$defs/fieldPredicate". public List Validate(JsonElement instance, string refText) { var errors = new List(); var (schema, file) = Resolve(refText, currentFile: ""); ValidateAgainst(instance, schema, file, "$", errors); return errors; } private JsonElement LoadDoc(string fileName) { if (_docCache.TryGetValue(fileName, out var cached)) return cached; var path = Path.Combine(_schemaDir, fileName); var doc = JsonDocument.Parse(File.ReadAllText(path)).RootElement; _docCache[fileName] = doc; return doc; } private (JsonElement Schema, string File) Resolve(string refText, string currentFile) { var hash = refText.IndexOf('#'); string file; string pointer; if (hash < 0) { file = refText; pointer = ""; } else { var filePart = refText[..hash]; file = filePart.Length == 0 ? currentFile : filePart; pointer = refText[(hash + 1)..]; } var doc = LoadDoc(file); if (string.IsNullOrEmpty(pointer) || pointer == "/") return (doc, file); var current = doc; foreach (var segment in pointer.TrimStart('/').Split('/')) { var key = segment.Replace("~1", "/").Replace("~0", "~"); current = current.GetProperty(key); } return (current, file); } private void ValidateAgainst(JsonElement instance, JsonElement schema, string file, string path, List errors) { CheckSupportedKeywords(schema, path, errors); if (schema.TryGetProperty("$ref", out var refProp)) { var (resolved, resolvedFile) = Resolve(refProp.GetString()!, file); ValidateAgainst(instance, resolved, resolvedFile, path, errors); return; } if (schema.TryGetProperty("oneOf", out var oneOf)) { var matches = 0; var branchErrors = new List(); foreach (var branch in oneOf.EnumerateArray()) { var local = new List(); ValidateAgainst(instance, branch, file, path, local); if (local.Count == 0) matches++; else branchErrors.AddRange(local); } if (matches != 1) { errors.Add($"{path}: oneOf matched {matches} branches (want exactly 1); branch errors: {string.Join(" | ", branchErrors)}"); } } if (schema.TryGetProperty("not", out var not)) { var local = new List(); ValidateAgainst(instance, not, file, path, local); if (local.Count == 0) errors.Add($"{path}: matched the \"not\" subschema, which it must not"); } if (schema.TryGetProperty("const", out var constEl)) { if (!JsonElementDeepEquals(instance, constEl)) errors.Add($"{path}: expected const {constEl}, got {instance}"); } if (schema.TryGetProperty("enum", out var enumEl)) { if (!enumEl.EnumerateArray().Any(candidate => JsonElementDeepEquals(instance, candidate))) errors.Add($"{path}: value {instance} is not one of the declared enum values"); } if (schema.TryGetProperty("type", out var typeEl)) { var allowed = typeEl.ValueKind == JsonValueKind.Array ? typeEl.EnumerateArray().Select(t => t.GetString()!).ToArray() : new[] { typeEl.GetString()! }; if (!allowed.Any(t => MatchesType(instance, t))) errors.Add($"{path}: kind {instance.ValueKind} does not satisfy type [{string.Join(",", allowed)}]"); } // Null short-circuits the value-shaped checks below (a nilled key still satisfies a // ["...","null"] type, but has no length/properties/items of its own to check). if (instance.ValueKind == JsonValueKind.Null) { return; } if (instance.ValueKind == JsonValueKind.String) { var value = instance.GetString()!; if (schema.TryGetProperty("minLength", out var minLength) && value.Length < minLength.GetInt32()) errors.Add($"{path}: string shorter than minLength {minLength.GetInt32()}"); if (schema.TryGetProperty("maxLength", out var maxLength) && value.Length > maxLength.GetInt32()) errors.Add($"{path}: string longer than maxLength {maxLength.GetInt32()}"); if (schema.TryGetProperty("pattern", out var pattern)) { // R-X: a full match, not Regex.IsMatch's "search anywhere" - and not .NET's own `$`, // which (unlike ECMA-262's) matches immediately before a single trailing newline even // without RegexOptions.Multiline, so an author-anchored "^...$" pattern here would // wrongly accept e.g. "5\n" for a pattern that means "exactly one digit". var match = Regex.Match(value, pattern.GetString()!); if (!match.Success || match.Index != 0 || match.Length != value.Length) errors.Add($"{path}: \"{value}\" does not match pattern {pattern.GetString()}"); } } if (instance.ValueKind == JsonValueKind.Number) { if (schema.TryGetProperty("minimum", out var minimum) && instance.GetDouble() < minimum.GetDouble()) errors.Add($"{path}: {instance} is less than minimum {minimum}"); if (schema.TryGetProperty("maximum", out var maximum) && instance.GetDouble() > maximum.GetDouble()) errors.Add($"{path}: {instance} is greater than maximum {maximum}"); } if (instance.ValueKind == JsonValueKind.Array) { var items = instance.EnumerateArray().ToArray(); if (schema.TryGetProperty("minItems", out var minItems) && items.Length < minItems.GetInt32()) errors.Add($"{path}: array shorter than minItems {minItems.GetInt32()}"); if (schema.TryGetProperty("maxItems", out var maxItems) && items.Length > maxItems.GetInt32()) errors.Add($"{path}: array longer than maxItems {maxItems.GetInt32()}"); if (schema.TryGetProperty("items", out var itemSchema)) { for (var i = 0; i < items.Length; i++) ValidateAgainst(items[i], itemSchema, file, $"{path}[{i}]", errors); } } if (instance.ValueKind == JsonValueKind.Object) { var propertyNames = instance.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal); if (schema.TryGetProperty("required", out var required)) { foreach (var name in required.EnumerateArray().Select(r => r.GetString()!)) { if (!propertyNames.Contains(name)) errors.Add($"{path}: missing required property \"{name}\""); } } var declared = schema.TryGetProperty("properties", out var properties) ? properties.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal) : new HashSet(StringComparer.Ordinal); if (schema.TryGetProperty("additionalProperties", out var additional) && additional.ValueKind == JsonValueKind.False) { foreach (var name in propertyNames) { if (!declared.Contains(name)) errors.Add($"{path}: property \"{name}\" is not declared and additionalProperties is false"); } } if (properties.ValueKind == JsonValueKind.Object) { foreach (var propertySchema in properties.EnumerateObject()) { if (instance.TryGetProperty(propertySchema.Name, out var propertyValue)) ValidateAgainst(propertyValue, propertySchema.Value, file, $"{path}.{propertySchema.Name}", errors); } } } } /// /// R-X: refuses a schema node that uses a keyword this validator gives no behaviour to - allOf, /// anyOf, if/then/else and uniqueItems are the ones found in the committed contracts at the time /// this check was added. Before this, such a keyword was silently invisible: an instance that /// violated it still validated clean, because nothing ever looked at the keyword to begin with. /// Only checks the schema node actually reached during this validation (the same nodes /// ValidateAgainst recurses into) - it is not a whole-document schema linter. /// private static void CheckSupportedKeywords(JsonElement schema, string path, List errors) { if (schema.ValueKind != JsonValueKind.Object) return; foreach (var property in schema.EnumerateObject()) { if (!ImplementedKeywords.Contains(property.Name) && !MetadataKeywords.Contains(property.Name)) errors.Add($"{path}: schema uses keyword \"{property.Name}\", which this validator does not implement"); } } private static bool MatchesType(JsonElement instance, string type) => type switch { "object" => instance.ValueKind == JsonValueKind.Object, "array" => instance.ValueKind == JsonValueKind.Array, "string" => instance.ValueKind == JsonValueKind.String, "boolean" => instance.ValueKind is JsonValueKind.True or JsonValueKind.False, "null" => instance.ValueKind == JsonValueKind.Null, "integer" => instance.ValueKind == JsonValueKind.Number && instance.TryGetInt64(out _), "number" => instance.ValueKind == JsonValueKind.Number, _ => throw new NotSupportedException($"Unsupported schema type \"{type}\".") }; private static bool JsonElementDeepEquals(JsonElement a, JsonElement b) { if (a.ValueKind != b.ValueKind) return false; return a.ValueKind switch { JsonValueKind.String => a.GetString() == b.GetString(), JsonValueKind.Number => a.GetRawText() == b.GetRawText(), JsonValueKind.True or JsonValueKind.False or JsonValueKind.Null => true, _ => a.GetRawText() == b.GetRawText() }; } } }