diff --git a/src/libraries/System.Text.Json/src/System.Text.Json.csproj b/src/libraries/System.Text.Json/src/System.Text.Json.csproj index 5bd278908ad55c..831bfa5b6b052e 100644 --- a/src/libraries/System.Text.Json/src/System.Text.Json.csproj +++ b/src/libraries/System.Text.Json/src/System.Text.Json.csproj @@ -387,8 +387,16 @@ The System.Text.Json library is built-in as part of the shared framework in .NET + - + + + + + diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/JsonHelpers.cs b/src/libraries/System.Text.Json/src/System/Text/Json/JsonHelpers.cs index 5f9a6847015165..bc2d17f5f6926d 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/JsonHelpers.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/JsonHelpers.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; namespace System.Text.Json { @@ -174,5 +175,19 @@ public static bool HasAllSet(this BitArray bitArray) return true; } #endif + + /// + /// Gets a Regex instance for recognizing integer representations of enums. + /// + public static readonly Regex IntegerRegex = CreateIntegerRegex(); + private const string IntegerRegexPattern = @"^\s*(\+|\-)?[0-9]+\s*$"; + private const int IntegerRegexTimeoutMs = 200; + +#if NETCOREAPP + [GeneratedRegex(IntegerRegexPattern, RegexOptions.None, matchTimeoutMilliseconds: IntegerRegexTimeoutMs)] + private static partial Regex CreateIntegerRegex(); +#else + private static Regex CreateIntegerRegex() => new(IntegerRegexPattern, RegexOptions.Compiled, TimeSpan.FromMilliseconds(IntegerRegexTimeoutMs)); +#endif } } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs index 4e231218de94a1..568fdf7519c214 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs @@ -107,14 +107,15 @@ public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerial } #if NETCOREAPP - if (TryParseEnumCore(ref reader, options, out T value)) + if (TryParseEnumCore(ref reader, out T value)) #else string? enumString = reader.GetString(); - if (TryParseEnumCore(enumString, options, out T value)) + if (TryParseEnumCore(enumString, out T value)) #endif { return value; } + #if NETCOREAPP return ReadEnumUsingNamingPolicy(reader.GetString()); #else @@ -270,9 +271,9 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions internal override T ReadAsPropertyNameCore(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { #if NETCOREAPP - bool success = TryParseEnumCore(ref reader, options, out T value); + bool success = TryParseEnumCore(ref reader, out T value); #else - bool success = TryParseEnumCore(reader.GetString(), options, out T value); + bool success = TryParseEnumCore(reader.GetString(), out T value); #endif if (!success) @@ -283,7 +284,7 @@ internal override T ReadAsPropertyNameCore(ref Utf8JsonReader reader, Type typeT return value; } - internal override unsafe void WriteAsPropertyNameCore(Utf8JsonWriter writer, T value, JsonSerializerOptions options, bool isWritingExtensionDataProperty) + internal override void WriteAsPropertyNameCore(Utf8JsonWriter writer, T value, JsonSerializerOptions options, bool isWritingExtensionDataProperty) { ulong key = ConvertToUInt64(value); @@ -322,43 +323,50 @@ internal override unsafe void WriteAsPropertyNameCore(Utf8JsonWriter writer, T v return; } -#pragma warning disable 8500 // address of managed type switch (s_enumTypeCode) { + // Use Unsafe.As instead of raw pointers for .NET Standard support. + // https://github.com/dotnet/runtime/issues/84895 + case TypeCode.Int32: - writer.WritePropertyName(*(int*)&value); + writer.WritePropertyName(Unsafe.As(ref value)); break; case TypeCode.UInt32: - writer.WritePropertyName(*(uint*)&value); + writer.WritePropertyName(Unsafe.As(ref value)); break; case TypeCode.UInt64: - writer.WritePropertyName(*(ulong*)&value); + writer.WritePropertyName(Unsafe.As(ref value)); break; case TypeCode.Int64: - writer.WritePropertyName(*(long*)&value); + writer.WritePropertyName(Unsafe.As(ref value)); break; case TypeCode.Int16: - writer.WritePropertyName(*(short*)&value); + writer.WritePropertyName(Unsafe.As(ref value)); break; case TypeCode.UInt16: - writer.WritePropertyName(*(ushort*)&value); + writer.WritePropertyName(Unsafe.As(ref value)); break; case TypeCode.Byte: - writer.WritePropertyName(*(byte*)&value); + writer.WritePropertyName(Unsafe.As(ref value)); break; case TypeCode.SByte: - writer.WritePropertyName(*(sbyte*)&value); + writer.WritePropertyName(Unsafe.As(ref value)); break; default: ThrowHelper.ThrowJsonException(); break; } -#pragma warning restore 8500 } + private bool TryParseEnumCore( #if NETCOREAPP - private static bool TryParseEnumCore(ref Utf8JsonReader reader, JsonSerializerOptions options, out T value) + ref Utf8JsonReader reader, +#else + string? source, +#endif + out T value) { +#if NETCOREAPP char[]? rentedBuffer = null; int bufferLength = reader.ValueLength; @@ -368,28 +376,29 @@ private static bool TryParseEnumCore(ref Utf8JsonReader reader, JsonSerializerOp int charsWritten = reader.CopyString(charBuffer); ReadOnlySpan source = charBuffer.Slice(0, charsWritten); +#endif - // Try parsing case sensitive first - bool success = Enum.TryParse(source, out T result) || Enum.TryParse(source, ignoreCase: true, out result); + bool success; + if ((_converterOptions & EnumConverterOptions.AllowNumbers) != 0 || !JsonHelpers.IntegerRegex.IsMatch(source)) + { + // Try parsing case sensitive first + success = Enum.TryParse(source, out value) || Enum.TryParse(source, ignoreCase: true, out value); + } + else + { + success = false; + value = default; + } +#if NETCOREAPP if (rentedBuffer != null) { charBuffer.Slice(0, charsWritten).Clear(); ArrayPool.Shared.Return(rentedBuffer); } - - value = result; - return success; - } -#else - private static bool TryParseEnumCore(string? enumString, JsonSerializerOptions _, out T value) - { - // Try parsing case sensitive first - bool success = Enum.TryParse(enumString, out T result) || Enum.TryParse(enumString, ignoreCase: true, out result); - value = result; +#endif return success; } -#endif private T ReadEnumUsingNamingPolicy(string? enumString) { diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.FSharp.Tests/EnumTests.fs b/src/libraries/System.Text.Json/tests/System.Text.Json.FSharp.Tests/EnumTests.fs index 5285704d499271..07f1e2dc6a3da8 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.FSharp.Tests/EnumTests.fs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.FSharp.Tests/EnumTests.fs @@ -29,6 +29,9 @@ let goodEnumJsonStr = $"\"{goodEnum}\"" let options = new JsonSerializerOptions() options.Converters.Add(new JsonStringEnumConverter()) +let optionsDisableNumeric = new JsonSerializerOptions() +optionsDisableNumeric.Converters.Add(new JsonStringEnumConverter(null, false)) + [] let ``Deserialize With Exception If Enum Contains Special Char`` () = let ex = Assert.Throws(fun () -> JsonSerializer.Deserialize(badEnumJsonStr, options) |> ignore) @@ -58,3 +61,35 @@ let ``Fail Serialize Good Value Of Bad Enum Type`` () = let ex = Assert.Throws(fun () -> JsonSerializer.Serialize(badEnumWithGoodValue, options) |> ignore) Assert.Equal(typeof, ex.InnerException.GetType()) Assert.Equal("Enum type 'BadEnum' uses unsupported identifer name 'There's a comma, in my name'.", ex.InnerException.Message) + +type NumericLabelEnum = + | ``1`` = 1 + | ``2`` = 2 + | ``3`` = 4 + +[] +[] +[] +[] +[] +[] +[] +[] +[] +[] +[] +let ``Fail Deserialize Numeric label Of Enum When Disallow Integer Values`` (numericValueJsonStr: string) = + Assert.Throws(fun () -> JsonSerializer.Deserialize(numericValueJsonStr, optionsDisableNumeric) |> ignore) + +[] +[] +[] +let ``Successful Deserialize Numeric label Of Enum When Allowing Integer Values`` (numericValueJsonStr: string, expectedEnumValue: NumericLabelEnum) = + let actual = JsonSerializer.Deserialize(numericValueJsonStr, options) + Assert.Equal(expectedEnumValue, actual) + +[] +let ``Successful Deserialize Numeric label Of Enum But as Underlying value When Allowing Integer Values`` () = + let actual = JsonSerializer.Deserialize("\"3\"", options) + Assert.NotEqual(NumericLabelEnum.``3``, actual) + Assert.Equal(LanguagePrimitives.EnumOfValue 3, actual) \ No newline at end of file diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/EnumConverterTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/EnumConverterTests.cs index ff06d27d437599..84e3f946dca9e0 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/EnumConverterTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/EnumConverterTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Diagnostics; using System.Globalization; using System.IO; using System.Reflection; @@ -117,6 +118,27 @@ public void ConvertDayOfWeek(bool useGenericVariant) // Not permitting integers should throw options = CreateStringEnumOptionsForType(useGenericVariant, allowIntegerValues: false); Assert.Throws(() => JsonSerializer.Serialize((DayOfWeek)(-1), options)); + + // Quoted numbers should throw + Assert.Throws(() => JsonSerializer.Deserialize("1", options)); + Assert.Throws(() => JsonSerializer.Deserialize("-1", options)); + Assert.Throws(() => JsonSerializer.Deserialize(@"""1""", options)); + Assert.Throws(() => JsonSerializer.Deserialize(@"""+1""", options)); + Assert.Throws(() => JsonSerializer.Deserialize(@"""-1""", options)); + Assert.Throws(() => JsonSerializer.Deserialize(@""" 1 """, options)); + Assert.Throws(() => JsonSerializer.Deserialize(@""" +1 """, options)); + Assert.Throws(() => JsonSerializer.Deserialize(@""" -1 """, options)); + + day = JsonSerializer.Deserialize(@"""Monday""", options); + Assert.Equal(DayOfWeek.Monday, day); + + // Numbers-formatted json string should first consider naming policy + options = CreateStringEnumOptionsForType(useGenericVariant, new ToEnumNumberNamingPolicy(), false); + day = JsonSerializer.Deserialize(@"""1""", options); + Assert.Equal(DayOfWeek.Monday, day); + + options = CreateStringEnumOptionsForType(useGenericVariant, new ToLowerNamingPolicy(), false); + Assert.Throws(() => JsonSerializer.Deserialize(@"""1""", options)); } public class ToLowerNamingPolicy : JsonNamingPolicy @@ -174,10 +196,23 @@ public void ConvertFileAttributes(bool useGenericVariant) json = JsonSerializer.Serialize((FileAttributes)(-1), options); Assert.Equal(@"-1", json); - // Not permitting integers should throw options = CreateStringEnumOptionsForType(useGenericVariant, allowIntegerValues: false); + // Not permitting integers should throw Assert.Throws(() => JsonSerializer.Serialize((FileAttributes)(-1), options)); + // Numbers should throw + Assert.Throws(() => JsonSerializer.Deserialize("1", options)); + Assert.Throws(() => JsonSerializer.Deserialize("-1", options)); + Assert.Throws(() => JsonSerializer.Deserialize(@"""1""", options)); + Assert.Throws(() => JsonSerializer.Deserialize(@"""+1""", options)); + Assert.Throws(() => JsonSerializer.Deserialize(@"""-1""", options)); + Assert.Throws(() => JsonSerializer.Deserialize(@""" 1 """, options)); + Assert.Throws(() => JsonSerializer.Deserialize(@""" +1 """, options)); + Assert.Throws(() => JsonSerializer.Deserialize(@""" -1 """, options)); + + attributes = JsonSerializer.Deserialize(@"""ReadOnly""", options); + Assert.Equal(FileAttributes.ReadOnly, attributes); + // Flag values honor naming policy correctly options = CreateStringEnumOptionsForType(useGenericVariant, new SimpleSnakeCasePolicy()); @@ -716,22 +751,69 @@ public static void EnumDictionaryKeySerialization() JsonTestHelper.AssertJsonEqual(expected, JsonSerializer.Serialize(dict, options)); } + [Theory] + [InlineData(typeof(SampleEnumByte), true)] + [InlineData(typeof(SampleEnumByte), false)] + [InlineData(typeof(SampleEnumSByte), true)] + [InlineData(typeof(SampleEnumSByte), false)] + [InlineData(typeof(SampleEnumInt16), true)] + [InlineData(typeof(SampleEnumInt16), false)] + [InlineData(typeof(SampleEnumUInt16), true)] + [InlineData(typeof(SampleEnumUInt16), false)] + [InlineData(typeof(SampleEnumInt32), true)] + [InlineData(typeof(SampleEnumInt32), false)] + [InlineData(typeof(SampleEnumUInt32), true)] + [InlineData(typeof(SampleEnumUInt32), false)] + [InlineData(typeof(SampleEnumInt64), true)] + [InlineData(typeof(SampleEnumInt64), false)] + [InlineData(typeof(SampleEnumUInt64), true)] + [InlineData(typeof(SampleEnumUInt64), false)] + public static void DeserializeNumericStringWithAllowIntegerValuesAsFalse(Type enumType, bool useGenericVariant) + { + JsonSerializerOptions options = CreateStringEnumOptionsForType(enumType, useGenericVariant, allowIntegerValues: false); + + Assert.Throws(() => JsonSerializer.Deserialize(@"""1""", enumType, options)); + Assert.Throws(() => JsonSerializer.Deserialize(@"""+1""", enumType, options)); + Assert.Throws(() => JsonSerializer.Deserialize(@"""-1""", enumType, options)); + Assert.Throws(() => JsonSerializer.Deserialize(@""" 1 """, enumType, options)); + Assert.Throws(() => JsonSerializer.Deserialize(@""" +1 """, enumType, options)); + Assert.Throws(() => JsonSerializer.Deserialize(@""" -1 """, enumType, options)); + Assert.Throws(() => JsonSerializer.Deserialize(@$"""{ulong.MaxValue}""", enumType, options)); + Assert.Throws(() => JsonSerializer.Deserialize(@$""" {ulong.MaxValue} """, enumType, options)); + Assert.Throws(() => JsonSerializer.Deserialize(@$"""+{ulong.MaxValue}""", enumType, options)); + Assert.Throws(() => JsonSerializer.Deserialize(@$""" +{ulong.MaxValue} """, enumType, options)); + Assert.Throws(() => JsonSerializer.Deserialize(@$"""{long.MinValue}""", enumType, options)); + Assert.Throws(() => JsonSerializer.Deserialize(@$""" {long.MinValue} """, enumType, options)); + } + + private class ToEnumNumberNamingPolicy : JsonNamingPolicy where T : struct, Enum + { + public override string ConvertName(string name) => Enum.TryParse(name, out T value) ? value.ToString("D") : name; + } + private class ZeroAppenderPolicy : JsonNamingPolicy { public override string ConvertName(string name) => name + "0"; } - private static JsonSerializerOptions CreateStringEnumOptionsForType(bool useGenericVariant, JsonNamingPolicy? namingPolicy = null, bool allowIntegerValues = true) where TEnum : struct, Enum + private static JsonSerializerOptions CreateStringEnumOptionsForType(Type enumType, bool useGenericVariant, JsonNamingPolicy? namingPolicy = null, bool allowIntegerValues = true) { + Debug.Assert(enumType.IsEnum); + return new JsonSerializerOptions { Converters = { useGenericVariant - ? new JsonStringEnumConverter(namingPolicy, allowIntegerValues) - : new JsonStringEnumConverter(namingPolicy, allowIntegerValues) + ? (JsonConverter)Activator.CreateInstance(typeof(JsonStringEnumConverter<>).MakeGenericType(enumType), namingPolicy, allowIntegerValues) + : new JsonStringEnumConverter(namingPolicy, allowIntegerValues) } }; } + + private static JsonSerializerOptions CreateStringEnumOptionsForType(bool useGenericVariant, JsonNamingPolicy? namingPolicy = null, bool allowIntegerValues = true) where TEnum : struct, Enum + { + return CreateStringEnumOptionsForType(typeof(TEnum), useGenericVariant, namingPolicy, allowIntegerValues); + } } }