CBOR Writer: Use canonical NaN representation for NaN values (#92934)
authorTomasz Sowiński <tomeksowi@gmail.com>
Fri, 6 Oct 2023 20:11:55 +0000 (22:11 +0200)
committerGleb Balykov <g.balykov@samsung.com>
Fri, 15 Dec 2023 12:28:32 +0000 (15:28 +0300)
* Use canonical NaN representation for NaN values

RFC 7049 (CBOR) specifies "If NaN is an allowed value, it must always be represented as 0xf97e00". The only exception is when the user explicitly requests precision (FP size) is preserved.

The problem occurred for x86, C# defines NaN as 0.0/0.0 which yields -NaN on x86 FP units, which gets encoded as 0xf9fe00.

Fixes issue #92080

* Use canonical CBOR (positive) NaN in WriteHalf

* Put canonical CBOR NaNs into named constants

* Add CborReader tests to verify the previously emitted negative NaN bit patterns are still readable as NaN.

* Use only half-float canonical CBOR representation for NaNs

NaNs only get written as 4 or 8 bytes only in CTAP2 mode, which requires to preserve all bits anyway.

+ review fixes

src/libraries/System.Formats.Cbor/src/System/Formats/Cbor/HalfHelpers.netstandard.cs
src/libraries/System.Formats.Cbor/src/System/Formats/Cbor/Writer/CborWriter.Simple.cs
src/libraries/System.Formats.Cbor/src/System/Formats/Cbor/Writer/CborWriter.Simple.netcoreapp.cs
src/libraries/System.Formats.Cbor/src/System/Formats/Cbor/Writer/CborWriter.Simple.netstandard.cs
src/libraries/System.Formats.Cbor/tests/CborTestHelpers.netcoreapp.cs
src/libraries/System.Formats.Cbor/tests/CborTestHelpers.netstandard.cs
src/libraries/System.Formats.Cbor/tests/Reader/CborReaderTests.Simple.cs
src/libraries/System.Formats.Cbor/tests/System.Formats.Cbor.Tests.csproj
src/libraries/System.Formats.Cbor/tests/Writer/CborWriterTests.Array.cs
src/libraries/System.Formats.Cbor/tests/Writer/CborWriterTests.Simple.cs
src/libraries/System.Formats.Cbor/tests/Writer/CborWriterTests.Simple.netcoreapp.cs

index 36dfafd..674301c 100644 (file)
@@ -65,6 +65,11 @@ namespace System.Formats.Cbor
                 => CborHelpers.Int32BitsToSingle((int)(((sign ? 1U : 0U) << FloatSignShift) + ((uint)exp << FloatExponentShift) + sig));
         }
 
+        public static bool HalfIsNaN(ushort value)
+        {
+            return (value & ~((ushort)1 << HalfSignShift)) > HalfPositiveInfinityBits;
+        }
+
         private static (int Exp, uint Sig) NormSubnormalF16Sig(uint sig)
         {
             int shiftDist = LeadingZeroCount(sig) - 16 - 5;
index 6496d00..1b7e8dd 100644 (file)
@@ -7,6 +7,10 @@ namespace System.Formats.Cbor
 {
     public partial class CborWriter
     {
+        // CBOR RFC 8949 says: if NaN is an allowed value, and there is no intent to support NaN payloads or signaling NaNs, the protocol needs to pick a single representation, typically 0xf97e00. If that simple choice is not possible, specific attention will be needed for NaN handling.
+        // In this implementation "that simple choice is not possible" for CTAP2 mode (RequiresPreservingFloatPrecision), in which "representations of any floating-point values are not changed".
+        private const ushort PositiveQNaNBitsHalf = 0x7e00;
+
         // Implements major type 7 encoding per https://tools.ietf.org/html/rfc7049#section-2.1
 
         /// <summary>Writes a single-precision floating point number (major type 7).</summary>
@@ -130,7 +134,7 @@ namespace System.Formats.Cbor
         private static bool TryConvertDoubleToSingle(double value, out float result)
         {
             result = (float)value;
-            return BitConverter.DoubleToInt64Bits(result) == BitConverter.DoubleToInt64Bits(value);
+            return double.IsNaN(value) || BitConverter.DoubleToInt64Bits(result) == BitConverter.DoubleToInt64Bits(value);
         }
     }
 }
index 0b929e5..4c3b393 100644 (file)
@@ -21,7 +21,14 @@ namespace System.Formats.Cbor
         {
             EnsureWriteCapacity(1 + sizeof(short));
             WriteInitialByte(new CborInitialByte(CborMajorType.Simple, CborAdditionalInfo.Additional16BitData));
-            BinaryPrimitives.WriteHalfBigEndian(_buffer.AsSpan(_offset), value);
+            if (Half.IsNaN(value) && !CborConformanceModeHelpers.RequiresPreservingFloatPrecision(ConformanceMode))
+            {
+                BinaryPrimitives.WriteUInt16BigEndian(_buffer.AsSpan(_offset), PositiveQNaNBitsHalf);
+            }
+            else
+            {
+                BinaryPrimitives.WriteHalfBigEndian(_buffer.AsSpan(_offset), value);
+            }
             _offset += sizeof(short);
             AdvanceDataItemCounters();
         }
@@ -30,7 +37,7 @@ namespace System.Formats.Cbor
         internal static bool TryConvertSingleToHalf(float value, out Half result)
         {
             result = (Half)value;
-            return BitConverter.SingleToInt32Bits((float)result) == BitConverter.SingleToInt32Bits(value);
+            return float.IsNaN(value) || BitConverter.SingleToInt32Bits((float)result) == BitConverter.SingleToInt32Bits(value);
         }
     }
 }
index c57f8dd..f257ce1 100644 (file)
@@ -21,7 +21,14 @@ namespace System.Formats.Cbor
         {
             EnsureWriteCapacity(1 + sizeof(ushort));
             WriteInitialByte(new CborInitialByte(CborMajorType.Simple, CborAdditionalInfo.Additional16BitData));
-            CborHelpers.WriteHalfBigEndian(_buffer.AsSpan(_offset), value);
+            if (HalfHelpers.HalfIsNaN(value) && !CborConformanceModeHelpers.RequiresPreservingFloatPrecision(ConformanceMode))
+            {
+                BinaryPrimitives.WriteUInt16BigEndian(_buffer.AsSpan(_offset), PositiveQNaNBitsHalf);
+            }
+            else
+            {
+                CborHelpers.WriteHalfBigEndian(_buffer.AsSpan(_offset), value);
+            }
             _offset += sizeof(ushort);
             AdvanceDataItemCounters();
         }
@@ -30,7 +37,7 @@ namespace System.Formats.Cbor
         internal static bool TryConvertSingleToHalf(float value, out ushort result)
         {
             result = HalfHelpers.FloatToHalf(value);
-            return CborHelpers.SingleToInt32Bits(HalfHelpers.HalfToFloat(result)) == CborHelpers.SingleToInt32Bits(value);
+            return float.IsNaN(value) || CborHelpers.SingleToInt32Bits(HalfHelpers.HalfToFloat(result)) == CborHelpers.SingleToInt32Bits(value);
         }
     }
 }
index 6aaa045..772adc8 100644 (file)
@@ -6,5 +6,8 @@ namespace System.Formats.Cbor.Tests
     internal static class CborTestHelpers
     {
         public static readonly DateTimeOffset UnixEpoch = DateTimeOffset.UnixEpoch;
+
+        public static int SingleToInt32Bits(float value)
+            => BitConverter.SingleToInt32Bits(value);
     }
 }
index 80fb9cd..07e5957 100644 (file)
@@ -7,5 +7,8 @@ namespace System.Formats.Cbor.Tests
     {
         private const long UnixEpochTicks = 719162L /*Number of days from 1/1/0001 to 12/31/1969*/ * 10000 * 1000 * 60 * 60 * 24; /* Ticks per day.*/
         public static readonly DateTimeOffset UnixEpoch = new DateTimeOffset(UnixEpochTicks, TimeSpan.Zero);
+
+        public static unsafe int SingleToInt32Bits(float value)
+            => *((int*)&value);
     }
 }
index 1c71ba3..8ef7500 100644 (file)
@@ -18,6 +18,7 @@ namespace System.Formats.Cbor.Tests
         [InlineData(float.PositiveInfinity, "fa7f800000")]
         [InlineData(float.NegativeInfinity, "faff800000")]
         [InlineData(float.NaN, "fa7fc00000")]
+        [InlineData(float.NaN, "faffc00000")]
         public static void ReadSingle_SingleValue_HappyPath(float expectedResult, string hexEncoding)
         {
             byte[] encoding = hexEncoding.HexToByteArray();
@@ -36,6 +37,7 @@ namespace System.Formats.Cbor.Tests
         [InlineData(double.PositiveInfinity, "fb7ff0000000000000")]
         [InlineData(double.NegativeInfinity, "fbfff0000000000000")]
         [InlineData(double.NaN, "fb7ff8000000000000")]
+        [InlineData(double.NaN, "fbfff8000000000000")]
         public static void ReadDouble_SingleValue_HappyPath(double expectedResult, string hexEncoding)
         {
             byte[] encoding = hexEncoding.HexToByteArray();
@@ -52,6 +54,7 @@ namespace System.Formats.Cbor.Tests
         [InlineData(double.PositiveInfinity, "fa7f800000")]
         [InlineData(double.NegativeInfinity, "faff800000")]
         [InlineData(double.NaN, "fa7fc00000")]
+        [InlineData(double.NaN, "faffc00000")]
         public static void ReadDouble_SinglePrecisionValue_ShouldCoerceToDouble(double expectedResult, string hexEncoding)
         {
             byte[] encoding = hexEncoding.HexToByteArray();
@@ -73,6 +76,7 @@ namespace System.Formats.Cbor.Tests
         [InlineData(-4.0, "f9c400")]
         [InlineData(double.PositiveInfinity, "f97c00")]
         [InlineData(double.NaN, "f97e00")]
+        [InlineData(double.NaN, "f9fe00")]
         [InlineData(double.NegativeInfinity, "f9fc00")]
         public static void ReadDouble_HalfPrecisionValue_ShouldCoerceToDouble(double expectedResult, string hexEncoding)
         {
@@ -95,6 +99,7 @@ namespace System.Formats.Cbor.Tests
         [InlineData(-4.0, "f9c400")]
         [InlineData(float.PositiveInfinity, "f97c00")]
         [InlineData(float.NaN, "f97e00")]
+        [InlineData(float.NaN, "f9fe00")]
         [InlineData(float.NegativeInfinity, "f9fc00")]
         public static void ReadSingle_HalfPrecisionValue_ShouldCoerceToSingle(float expectedResult, string hexEncoding)
         {
index bf7b2f2..f096323 100644 (file)
@@ -7,6 +7,7 @@
     <NoWarn>$(NoWarn);CS8002</NoWarn>
     <!-- FSharp.Core: Could not find embedded resource 'FSharpOptimizationCompressedData.FSharp.Core' to remove in assembly 'FSharp.Core'. See https://github.com/dotnet/fsharp/pull/14395 -->
     <NoWarn>$(NoWarn);IL2040</NoWarn>
+    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
   </PropertyGroup>
   <ItemGroup>
     <Compile Include="$(CommonTestPath)System\Security\Cryptography\ByteUtils.cs">
index bf8863d..44c4640 100644 (file)
@@ -18,7 +18,7 @@ namespace System.Formats.Cbor.Tests
         [InlineData(new object[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25 }, "98190102030405060708090a0b0c0d0e0f101112131415161718181819")]
         [InlineData(new object[] { 1, -1, "", new byte[] { 7 } }, "840120604107")]
         [InlineData(new object[] { "lorem", "ipsum", "dolor" }, "83656c6f72656d65697073756d65646f6c6f72")]
-        [InlineData(new object?[] { false, null, float.NaN, double.PositiveInfinity }, "84f4f6f9fe00f97c00")]
+        [InlineData(new object?[] { false, null, float.NaN, double.PositiveInfinity }, "84f4f6f97e00f97c00")]
         public static void WriteArray_SimpleValues_HappyPath(object[] values, string expectedHexEncoding)
         {
             byte[] expectedEncoding = expectedHexEncoding.HexToByteArray();
@@ -48,7 +48,7 @@ namespace System.Formats.Cbor.Tests
         [InlineData(new object[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25 }, "9f0102030405060708090a0b0c0d0e0f101112131415161718181819ff")]
         [InlineData(new object[] { 1, -1, "", new byte[] { 7 } }, "9f0120604107ff")]
         [InlineData(new object[] { "lorem", "ipsum", "dolor" }, "9f656c6f72656d65697073756d65646f6c6f72ff")]
-        [InlineData(new object?[] { false, null, float.NaN, double.PositiveInfinity }, "9ff4f6f9fe00f97c00ff")]
+        [InlineData(new object?[] { false, null, float.NaN, double.PositiveInfinity }, "9ff4f6f97e00f97c00ff")]
         public static void WriteArray_IndefiniteLength_NoPatching_HappyPath(object[] values, string expectedHexEncoding)
         {
             byte[] expectedEncoding = expectedHexEncoding.HexToByteArray();
@@ -82,7 +82,7 @@ namespace System.Formats.Cbor.Tests
         [InlineData(new object[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25 }, "98190102030405060708090a0b0c0d0e0f101112131415161718181819")]
         [InlineData(new object[] { 1, -1, "", new byte[] { 7 } }, "840120604107")]
         [InlineData(new object[] { "lorem", "ipsum", "dolor" }, "83656c6f72656d65697073756d65646f6c6f72")]
-        [InlineData(new object?[] { false, null, float.NaN, double.PositiveInfinity }, "84f4f6f9fe00f97c00")]
+        [InlineData(new object?[] { false, null, float.NaN, double.PositiveInfinity }, "84f4f6f97e00f97c00")]
         public static void WriteArray_IndefiniteLength_WithPatching_HappyPath(object[] values, string expectedHexEncoding)
         {
             byte[] expectedEncoding = expectedHexEncoding.HexToByteArray();
index 8a0130c..c72ef2e 100644 (file)
@@ -16,7 +16,7 @@ namespace System.Formats.Cbor.Tests
         [InlineData(3.4028234663852886e+38, "fa7f7fffff")]
         [InlineData(float.PositiveInfinity, "f97c00")]
         [InlineData(float.NegativeInfinity, "f9fc00")]
-        [InlineData(float.NaN, "f9fe00")]
+        [InlineData(float.NaN, "f97e00")]
         public static void WriteSingle_SingleValue_HappyPath(float input, string hexExpectedEncoding)
         {
             byte[] expectedEncoding = hexExpectedEncoding.HexToByteArray();
@@ -26,9 +26,9 @@ namespace System.Formats.Cbor.Tests
         }
 
         [Theory]
-        [InlineData(float.NaN, "f9fe00", CborConformanceMode.Lax)]
-        [InlineData(float.NaN, "f9fe00", CborConformanceMode.Strict)]
-        [InlineData(float.NaN, "f9fe00", CborConformanceMode.Canonical)]
+        [InlineData(float.NaN, "f97e00", CborConformanceMode.Lax)]
+        [InlineData(float.NaN, "f97e00", CborConformanceMode.Strict)]
+        [InlineData(float.NaN, "f97e00", CborConformanceMode.Canonical)]
         public static void WriteSingle_NonCtapConformance_ShouldMinimizePrecision(float input, string hexExpectedEncoding, CborConformanceMode mode)
         {
             byte[] expectedEncoding = hexExpectedEncoding.HexToByteArray();
@@ -42,7 +42,6 @@ namespace System.Formats.Cbor.Tests
         [InlineData(3.4028234663852886e+38, "fa7f7fffff")]
         [InlineData(float.PositiveInfinity, "fa7f800000")]
         [InlineData(float.NegativeInfinity, "faff800000")]
-        [InlineData(float.NaN, "faffc00000")]
         public static void WriteSingle_Ctap2Conformance_ShouldPreservePrecision(float input, string hexExpectedEncoding)
         {
             byte[] expectedEncoding = hexExpectedEncoding.HexToByteArray();
@@ -51,6 +50,16 @@ namespace System.Formats.Cbor.Tests
             AssertHelper.HexEqual(expectedEncoding, writer.Encode());
         }
 
+        [Fact]
+        public static void WriteSingle_Ctap2Conformance_ShouldPreservePrecision_NaN()
+        {
+            // float.NaN may differ across architectures, in particular it's negative on x86 and positive elsewhere
+            byte[] expectedEncoding = ("fa" + CborTestHelpers.SingleToInt32Bits(float.NaN).ToString("x4")).HexToByteArray();
+            var writer = new CborWriter(CborConformanceMode.Ctap2Canonical);
+            writer.WriteSingle(float.NaN);
+            AssertHelper.HexEqual(expectedEncoding, writer.Encode());
+        }
+
         [Theory]
         [InlineData(1.1, "fb3ff199999999999a")]
         [InlineData(1.0e+300, "fb7e37e43c8800759c")]
@@ -58,7 +67,7 @@ namespace System.Formats.Cbor.Tests
         [InlineData(3.1415926, "fb400921fb4d12d84a")]
         [InlineData(double.PositiveInfinity, "f97c00")]
         [InlineData(double.NegativeInfinity, "f9fc00")]
-        [InlineData(double.NaN, "f9fe00")]
+        [InlineData(double.NaN, "f97e00")]
         public static void WriteDouble_SingleValue_HappyPath(double input, string hexExpectedEncoding)
         {
             byte[] expectedEncoding = hexExpectedEncoding.HexToByteArray();
@@ -68,9 +77,9 @@ namespace System.Formats.Cbor.Tests
         }
 
         [Theory]
-        [InlineData(double.NaN, "f9fe00", CborConformanceMode.Lax)]
-        [InlineData(double.NaN, "f9fe00", CborConformanceMode.Strict)]
-        [InlineData(double.NaN, "f9fe00", CborConformanceMode.Canonical)]
+        [InlineData(double.NaN, "f97e00", CborConformanceMode.Lax)]
+        [InlineData(double.NaN, "f97e00", CborConformanceMode.Strict)]
+        [InlineData(double.NaN, "f97e00", CborConformanceMode.Canonical)]
         [InlineData(65505, "fa477fe100", CborConformanceMode.Lax)]
         [InlineData(65505, "fa477fe100", CborConformanceMode.Strict)]
         [InlineData(65505, "fa477fe100", CborConformanceMode.Canonical)]
@@ -89,7 +98,6 @@ namespace System.Formats.Cbor.Tests
         [InlineData(3.1415926, "fb400921fb4d12d84a")]
         [InlineData(double.PositiveInfinity, "fb7ff0000000000000")]
         [InlineData(double.NegativeInfinity, "fbfff0000000000000")]
-        [InlineData(double.NaN, "fbfff8000000000000")]
         public static void WriteDouble_Ctap2Conformance_ShouldPreservePrecision(double input, string hexExpectedEncoding)
         {
             byte[] expectedEncoding = hexExpectedEncoding.HexToByteArray();
@@ -99,6 +107,16 @@ namespace System.Formats.Cbor.Tests
         }
 
         [Fact]
+        public static void WriteDouble_Ctap2Conformance_ShouldPreservePrecision_NaN()
+        {
+            // double.NaN may differ across architectures, in particular it's negative on x86 and positive elsewhere
+            byte[] expectedEncoding = ("fb" + BitConverter.DoubleToInt64Bits(double.NaN).ToString("x8")).HexToByteArray();
+            var writer = new CborWriter(CborConformanceMode.Ctap2Canonical);
+            writer.WriteDouble(double.NaN);
+            AssertHelper.HexEqual(expectedEncoding, writer.Encode());
+        }
+
+        [Fact]
         public static void WriteNull_SingleValue_HappyPath()
         {
             byte[] expectedEncoding = "f6".HexToByteArray();
index 3ef7ad9..b81d882 100644 (file)
@@ -18,7 +18,7 @@ namespace System.Formats.Cbor.Tests
         [InlineData(0.00006103515625, "f90400")]
         [InlineData(-4.0, "f9c400")]
         [InlineData(float.PositiveInfinity, "f97c00")]
-        [InlineData(float.NaN, "f9fe00")]
+        [InlineData(float.NaN, "f97e00")]
         [InlineData(float.NegativeInfinity, "f9fc00")]
         public static void WriteHalf_SingleValue_HappyPath(float input, string hexExpectedEncoding)
         {