Fix batch 4 (R-X, S-4/S-10): the error schema matches the wire, and the validator refuses a keyword it does not implement
CultNetErrorMessage is a MessagePack-CSharp map-mode object, so every declared key is written on the wire, nil included - the same fact R-S already established for the raw document header. cultnet.error.schema.json was missing routingHint entirely and typed code/details as always-present non-null values; Soul's P7 found real bytes failing it on exactly this. routingHint, code and details are now `required`, with nullable types matching CultNetErrorMessage/CultNetErrorDetails. A new test decodes real bytes for all four refusal codes (selection_invalid, cursor_stale, cursor_invalid, reference_outside_target) against the schema. MiniJsonSchemaValidator: - implements minimum, maximum, maxLength and maxItems, which were declared in committed schemas (fieldPredicate's number pattern, selection.limit, ...) but silently never checked; - anchors `pattern` to a full match (Index==0 && Length==value.Length) instead of Regex.IsMatch's "search anywhere", so .NET's `$` (which, unlike ECMA-262's, matches immediately before one trailing newline even without RegexOptions.Multiline) can no longer let e.g. "5\n" pass a schema author wrote as "exactly one digit"; - refuses a schema node that uses a keyword this validator gives no constraint semantics to, instead of silently ignoring it. Running every committed contracts/cultnet/*.schema.json schema through this found 12 such uses, none reached by any existing test: `allOf`/`if`/`then`/`else` in cultnet.database-change-raw.schema.json, and `uniqueItems` (six places) plus one `anyOf` in the cultmesh.verse-catalog-*.schema.json pair. contentEncoding, $schema/$id/$defs/title/description/default and the x-cult-runtime-type vendor extension are treated as metadata with no constraint semantics to implement, not refused. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
b3a8f6d195
commit
b7d77b708d
3 changed files with 135 additions and 16 deletions
|
|
@ -2,29 +2,44 @@
|
|||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.error.schema.json",
|
||||
"title": "CultNet Error Message",
|
||||
"description": "R-X (docs/cultnet-selection-cut.md, fix batch 4): CultNetErrorMessage is a MessagePack-CSharp map-mode object, so every declared key is written on the wire, nil included - routingHint, code and details are `required` here the same as cultnet.raw-document-header.schema.json's nullable fields, not optional just because a given refusal leaves them null.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"error"
|
||||
"error",
|
||||
"routingHint",
|
||||
"code",
|
||||
"details"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.error.v0" },
|
||||
"error": { "type": "string", "minLength": 1 },
|
||||
"routingHint": {
|
||||
"description": "Optional shard routing detail for an authority failure. Null for every selection/cursor refusal code (R-N).",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"required": ["shard", "reason"],
|
||||
"properties": {
|
||||
"shard": { "type": ["object", "null"] },
|
||||
"reason": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"code": {
|
||||
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it.",
|
||||
"type": "string",
|
||||
"enum": ["selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target"]
|
||||
"type": ["string", "null"],
|
||||
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target"]
|
||||
},
|
||||
"details": {
|
||||
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it.",
|
||||
"type": "object",
|
||||
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"required": ["field", "value", "asOf", "current"],
|
||||
"properties": {
|
||||
"field": { "description": "selection_invalid: the selection field that failed.", "type": "string" },
|
||||
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming.", "type": "string" },
|
||||
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": "integer", "minimum": 0 },
|
||||
"current": { "description": "cursor_stale: the answering server's current asOf.", "type": "integer", "minimum": 0 }
|
||||
"field": { "description": "selection_invalid: the selection field that failed.", "type": ["string", "null"] },
|
||||
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming.", "type": ["string", "null"] },
|
||||
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 },
|
||||
"current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,5 +161,44 @@ namespace GameCult.Networking.Tests
|
|||
|
||||
Assert.That(errors, Is.Empty, string.Join("; ", errors));
|
||||
}
|
||||
|
||||
// R-X (docs/cultnet-selection-cut.md, fix batch 4): CultNetErrorMessage is a MessagePack-CSharp
|
||||
// map-mode object - routingHint/code/details are always written, nil included, the same as any
|
||||
// other declared key. Soul's P7 found the schema disagreed with the real bytes on exactly this:
|
||||
// routingHint undeclared, additionalProperties:false, and details.asOf/current typed as a plain
|
||||
// integer instead of ["integer","null"]. This decodes real bytes for all four refusal codes.
|
||||
[Test]
|
||||
public void ErrorMessage_SelectionInvalidRealBytes_MatchesItsSchema()
|
||||
{
|
||||
var ex = new CultNetSelectionInvalidException("schemas", null, "selection.schemas must be non-empty.");
|
||||
AssertErrorMatchesSchema(CultNetErrorMessage.ForSelectionInvalid(ex));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ErrorMessage_CursorStaleRealBytes_MatchesItsSchema()
|
||||
{
|
||||
var ex = new CultNetSelectionCursorException("cursor_stale", "stale", asOf: 3, current: 7);
|
||||
AssertErrorMatchesSchema(CultNetErrorMessage.ForCursor(ex));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ErrorMessage_CursorInvalidRealBytes_MatchesItsSchema()
|
||||
{
|
||||
var ex = new CultNetSelectionCursorException("cursor_invalid", "The cursor does not decode.");
|
||||
AssertErrorMatchesSchema(CultNetErrorMessage.ForCursor(ex));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ErrorMessage_ReferenceOutsideTargetRealBytes_MatchesItsSchema()
|
||||
{
|
||||
var ex = new CultNetSelectionReferenceOutsideTargetException("citer-schema", "citer-key", "Design", "outside-schema", "outside-key");
|
||||
AssertErrorMatchesSchema(CultNetErrorMessage.ForReferenceOutsideTarget(ex));
|
||||
}
|
||||
|
||||
private static void AssertErrorMatchesSchema(CultNetErrorMessage message)
|
||||
{
|
||||
var validator = new MiniJsonSchemaValidator(SchemaDir());
|
||||
validator.AssertValid(ToJson(message), "cultnet.error.schema.json");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,14 +12,39 @@ 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, minItems, 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.
|
||||
/// 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 <see cref="ImplementedKeywords"/>/<see cref="MetadataKeywords"/> and
|
||||
/// <see cref="CheckSupportedKeywords"/>. 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.
|
||||
/// </summary>
|
||||
internal sealed class MiniJsonSchemaValidator
|
||||
{
|
||||
// Keywords this validator gives real constraint semantics to.
|
||||
private static readonly HashSet<string> 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<string> MetadataKeywords = new(StringComparer.Ordinal)
|
||||
{
|
||||
"$schema", "$id", "$defs", "title", "description", "default",
|
||||
"contentEncoding", "x-cult-runtime-type"
|
||||
};
|
||||
|
||||
private readonly string _schemaDir;
|
||||
private readonly Dictionary<string, JsonElement> _docCache = new(StringComparer.Ordinal);
|
||||
|
||||
|
|
@ -89,6 +114,8 @@ namespace GameCult.Networking.Tests
|
|||
|
||||
private void ValidateAgainst(JsonElement instance, JsonElement schema, string file, string path, List<string> errors)
|
||||
{
|
||||
CheckSupportedKeywords(schema, path, errors);
|
||||
|
||||
if (schema.TryGetProperty("$ref", out var refProp))
|
||||
{
|
||||
var (resolved, resolvedFile) = Resolve(refProp.GetString()!, file);
|
||||
|
|
@ -155,8 +182,26 @@ namespace GameCult.Networking.Tests
|
|||
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("pattern", out var pattern) && !Regex.IsMatch(value, pattern.GetString()!))
|
||||
errors.Add($"{path}: \"{value}\" does not match pattern {pattern.GetString()}");
|
||||
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)
|
||||
|
|
@ -164,6 +209,8 @@ namespace GameCult.Networking.Tests
|
|||
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++)
|
||||
|
|
@ -207,6 +254,24 @@ namespace GameCult.Networking.Tests
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static void CheckSupportedKeywords(JsonElement schema, string path, List<string> 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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue