From d5df74919ae60610b9fdc26ad383e0209b92f7c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 08:32:22 +0000 Subject: [PATCH] Refuse Float and Double data values that overflow to infinity The validator checked a Float or Double value only for being a JSON number, so 1e39 in a Float (or 1e309 in a Double) validated clean and then read back from the generated record as Infinity. Float, Double, and every vector, colour, quantity and semantic component stored in one are now held to the storage type's finite range, and the message states that range. SchemaDataValues.GetFloat, GetDouble and the vector/colour readers now treat a non-finite result as malformed and throw, instead of returning it. Fixes #355 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW3VYgr52Mw1KS9otcE --- Schema.Test/GeneratedDataEditorTests.cs | 16 ++++++ Schema.Test/SchemaDataValidatorTypeTests.cs | 56 +++++++++++++++++++++ Schema/Models/SchemaDataValidator.cs | 21 +++++++- Schema/Runtime/SchemaDataValues.cs | 7 +-- 4 files changed, 95 insertions(+), 5 deletions(-) diff --git a/Schema.Test/GeneratedDataEditorTests.cs b/Schema.Test/GeneratedDataEditorTests.cs index bb644c8..ff410a6 100644 --- a/Schema.Test/GeneratedDataEditorTests.cs +++ b/Schema.Test/GeneratedDataEditorTests.cs @@ -308,6 +308,22 @@ public void TestAMissingOrMisshapenValueThrows() Assert.ThrowsExactly(() => SchemaDataValues.GetString(record, "Missing")); } + [TestMethod] + public void TestANumberPastItsTypesRangeThrowsRatherThanReadingAsInfinity() + { + JsonObject record = new() + { + ["Speed"] = JsonNode.Parse("1e39"), + ["D"] = JsonNode.Parse("1e309"), + ["Position"] = JsonNode.Parse("[1, 1e39, 2]"), + }; + + Assert.ThrowsExactly(() => SchemaDataValues.GetFloat(record, "Speed")); + Assert.ThrowsExactly(() => SchemaDataValues.GetDouble(record, "D")); + Assert.ThrowsExactly(() => SchemaDataValues.GetVector3(record, "Position")); + Assert.AreEqual(1e39, SchemaDataValues.GetDouble(record, "Speed")); + } + [TestMethod] public void TestVectorsAndColoursRoundTrip() { diff --git a/Schema.Test/SchemaDataValidatorTypeTests.cs b/Schema.Test/SchemaDataValidatorTypeTests.cs index 5b42d74..b73610a 100644 --- a/Schema.Test/SchemaDataValidatorTypeTests.cs +++ b/Schema.Test/SchemaDataValidatorTypeTests.cs @@ -136,4 +136,60 @@ public void ATypeWithNoStoredFormIsReportedAsUnchecked() Assert.AreEqual(SchemaValidationSeverity.Warning, issues[0].Severity, type.DisplayName); } } + + // ---- floating-point overflow ----------------------------------------------- + + [TestMethod] + [DataRow("1e39")] + [DataRow("-1e39")] + public void AFloatPastFloatsRangeIsAnError(string value) + { + Collection issues = Validate(new Float(), value); + + Assert.HasCount(1, issues, string.Join("; ", issues)); + Assert.AreEqual(SchemaValidationSeverity.Error, issues[0].Severity); + Assert.Contains("range of Float", issues[0].Message); + } + + [TestMethod] + [DataRow("3.4028234663852886E+38")] + [DataRow("1e38")] + [DataRow("0")] + public void AFloatAtOrInsideFloatsRangeIsAccepted(string value) => + Assert.HasCount(0, Validate(new Float(), value)); + + [TestMethod] + public void ADoublePastDoublesRangeIsAnError() + { + Assert.HasCount(1, Validate(new Double(), "1e309")); + Assert.HasCount(0, Validate(new Double(), "1e39")); + } + + [TestMethod] + public void AFloatVectorComponentPastFloatsRangeIsAnError() + { + Collection issues = Validate(new Vector3(), "[1, 1e39, 2]"); + + Assert.HasCount(1, issues, string.Join("; ", issues)); + Assert.AreEqual("data.Value[1]", issues[0].Path); + } + + [TestMethod] + public void AColourComponentAndAQuantityPastFloatsRangeAreErrors() + { + Assert.HasCount(1, Validate(new ColorRGB(), "[0, 1e39, 0]")); + Assert.HasCount(1, Validate(Quantity("Mass"), "1e39")); + } + + [TestMethod] + public void ASemanticTypeOverAFloatIsHeldToFloatsRange() + { + SchemaClass schemaClass = ClassWith((schema, c) => + { + schema.AddSemanticType("Speed".As())!.SetUnderlyingType(new Float()); + c.AddMember("Value".As())!.SetType(new Semantic { SemanticTypeName = "Speed".As() }); + }); + + Assert.HasCount(1, SchemaDataValidator.ValidateData(schemaClass, """{ "Value": 1e39 }""", "data")); + } } diff --git a/Schema/Models/SchemaDataValidator.cs b/Schema/Models/SchemaDataValidator.cs index 3cb401f..ee4b666 100644 --- a/Schema/Models/SchemaDataValidator.cs +++ b/Schema/Models/SchemaDataValidator.cs @@ -262,7 +262,7 @@ private static void ValidateValue(BaseType type, JsonElement value, string path, break; case Float or Double: - Expect(value, JsonValueKind.Number, path, type, issues); + ExpectNumber(value, type, path, type, issues); break; case Types.String: @@ -399,7 +399,24 @@ private static void ExpectNumber(JsonElement value, BaseType storage, string pat return; } - Expect(value, JsonValueKind.Number, path, type, issues); + if (value.ValueKind != JsonValueKind.Number) + { + Expect(value, JsonValueKind.Number, path, type, issues); + return; + } + + // A number past what the storage holds reads back as an infinity rather than failing, so + // "1e39" in a Float passes every reader and arrives as one. Refuse it here, as the + // integral types already are and as schema validation already refuses such a default. + bool finite = value.TryGetDouble(out double number) && double.IsFinite(number); + if (represented is Float && !(finite && Math.Abs(number) <= float.MaxValue)) + { + issues.Add(Error(path, $"Expected a number within the range of Float (±{float.MaxValue.ToString("R", CultureInfo.InvariantCulture)}) for type '{type.DisplayName}' but found {value.GetRawText()}.")); + } + else if (represented is Double && !finite) + { + issues.Add(Error(path, $"Expected a number within the range of Double (±{double.MaxValue.ToString("R", CultureInfo.InvariantCulture)}) for type '{type.DisplayName}' but found {value.GetRawText()}.")); + } } private static void ValidateEnumValue(Types.Enum enumType, JsonElement value, string path, Collection issues) diff --git a/Schema/Runtime/SchemaDataValues.cs b/Schema/Runtime/SchemaDataValues.cs index e106855..5b3ac90 100644 --- a/Schema/Runtime/SchemaDataValues.cs +++ b/Schema/Runtime/SchemaDataValues.cs @@ -115,7 +115,7 @@ public static long GetLong(JsonObject record, string name) => /// The member's declared name. /// The value. public static float GetFloat(JsonObject record, string name) => - ReadNumber(record, name, static (JsonElement e, out float v) => e.TryGetSingle(out v)); + ReadNumber(record, name, static (JsonElement e, out float v) => e.TryGetSingle(out v) && float.IsFinite(v)); /// Writes a Float member. /// The record. @@ -128,7 +128,7 @@ public static float GetFloat(JsonObject record, string name) => /// The member's declared name. /// The value. public static double GetDouble(JsonObject record, string name) => - ReadNumber(record, name, static (JsonElement e, out double v) => e.TryGetDouble(out v)); + ReadNumber(record, name, static (JsonElement e, out double v) => e.TryGetDouble(out v) && double.IsFinite(v)); /// Writes a Double member. /// The record. @@ -342,7 +342,8 @@ private static float[] GetComponents(JsonObject record, string name, int count) { JsonNode? component = array[i]; if (component?.GetValueKind() != JsonValueKind.Number - || !JsonSerializer.SerializeToElement(component).TryGetSingle(out components[i])) + || !JsonSerializer.SerializeToElement(component).TryGetSingle(out components[i]) + || !float.IsFinite(components[i])) { throw Malformed(name, $"component {i} is not a number"); }