Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JsonSerializer doesn't support valid TimeSpan formats #100538

Closed
eerhardt opened this issue Apr 2, 2024 · 18 comments · Fixed by #102091
Closed

JsonSerializer doesn't support valid TimeSpan formats #100538

eerhardt opened this issue Apr 2, 2024 · 18 comments · Fixed by #102091
Labels
area-System.Text.Json enhancement Product code improvement that does NOT require public API changes/additions
Milestone

Comments

@eerhardt
Copy link
Member

eerhardt commented Apr 2, 2024

Description

When trying to deserialize TimeSpan strings from JSON into an object using JsonSerializer.Deserialize, not all valid TimeSpan formats are supported.

This is inconsistent with our other APIs that deserialize TimeSpan values from strings (for example Microsoft.Extensions.Configuration.Binder). It can cause problems when people are reading TimeSpan information from other sources and can't control the format being written.

Reproduction Steps

using Newtonsoft.Json;
using System.Buffers.Text;
using System.Text;

string[] timeSpanStrings = ["00:01", "10"];

foreach (string timeSpanString in timeSpanStrings)
{
    TimeSpan timeSpan = TimeSpan.Parse(timeSpanString);
    Console.WriteLine(timeSpan);

    var timeSpanStringUtf8 = Encoding.UTF8.GetBytes(timeSpanString);
    Utf8Parser.TryParse(timeSpanStringUtf8, out timeSpan, out _, standardFormat: 'c');
    Console.WriteLine(timeSpan);

    string json = $$"""
    {
      "Delay": "{{timeSpanString}}"
    }
    """;

    var options = JsonConvert.DeserializeObject<MyOptions>(json);
    Console.WriteLine(options?.Delay);

    try
    {
        options = System.Text.Json.JsonSerializer.Deserialize<MyOptions>(json);
        Console.WriteLine(options?.Delay);
    }
    catch (Exception e)
    {
        Console.WriteLine(e);
    }

    Console.WriteLine("----");
}
public class MyOptions
{
    public TimeSpan Delay { get; set; }
}

Expected behavior

The above code should print each TimeSpan value in timeSpanStrings 4 times:

  • TimeSpan.Parse
  • Utf8Parser.TryParse
  • The result of Newtonsoft.Json's JsonConvert.DeserializeObject
  • The result from JsonSerializer.Deserialize

Actual behavior

00:01:00
00:01:00
00:01:00
System.Text.Json.JsonException: The JSON value could not be converted to System.TimeSpan. Path: $.Delay | LineNumber: 1 | BytePositionInLine: 18.
 ---> System.FormatException: The JSON value is not in a supported TimeSpan format.
   at System.Text.Json.ThrowHelper.ThrowFormatException(DataType dataType)
   at System.Text.Json.Serialization.Converters.TimeSpanConverter.ReadCore(Utf8JsonReader& reader)
   at System.Text.Json.Serialization.Metadata.JsonPropertyInfo`1.ReadJsonAndSetMember(Object obj, ReadStack& state, Utf8JsonReader& reader)
   at System.Text.Json.Serialization.Converters.ObjectDefaultConverter`1.OnTryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
   at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value, Boolean& isPopulatedValue)
   at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader& reader, JsonSerializerOptions options, ReadStack& state)
   --- End of inner exception stack trace ---
   at System.Text.Json.ThrowHelper.ReThrowWithPath(ReadStack& state, Utf8JsonReader& reader, Exception ex)
   at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader& reader, JsonSerializerOptions options, ReadStack& state)
   at System.Text.Json.JsonSerializer.ReadFromSpan[TValue](ReadOnlySpan`1 utf8Json, JsonTypeInfo`1 jsonTypeInfo, Nullable`1 actualByteCount)
   at System.Text.Json.JsonSerializer.ReadFromSpan[TValue](ReadOnlySpan`1 json, JsonTypeInfo`1 jsonTypeInfo)
   at System.Text.Json.JsonSerializer.Deserialize[TValue](String json, JsonSerializerOptions options)
   at Program.<Main>$(String[] args) in C:\Users\eerhardt\source\repos\ConsoleApp115\ConsoleApp115\Program.cs:line 27
----
10.00:00:00
10.00:00:00
10.00:00:00
System.Text.Json.JsonException: The JSON value could not be converted to System.TimeSpan. Path: $.Delay | LineNumber: 1 | BytePositionInLine: 15.
 ---> System.FormatException: The JSON value is not in a supported TimeSpan format.
   at System.Text.Json.ThrowHelper.ThrowFormatException(DataType dataType)
   at System.Text.Json.Serialization.Converters.TimeSpanConverter.ReadCore(Utf8JsonReader& reader)
   at System.Text.Json.Serialization.Metadata.JsonPropertyInfo`1.ReadJsonAndSetMember(Object obj, ReadStack& state, Utf8JsonReader& reader)
   at System.Text.Json.Serialization.Converters.ObjectDefaultConverter`1.OnTryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
   at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value, Boolean& isPopulatedValue)
   at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader& reader, JsonSerializerOptions options, ReadStack& state)
   --- End of inner exception stack trace ---
   at System.Text.Json.ThrowHelper.ReThrowWithPath(ReadStack& state, Utf8JsonReader& reader, Exception ex)
   at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader& reader, JsonSerializerOptions options, ReadStack& state)
   at System.Text.Json.JsonSerializer.ReadFromSpan[TValue](ReadOnlySpan`1 utf8Json, JsonTypeInfo`1 jsonTypeInfo, Nullable`1 actualByteCount)
   at System.Text.Json.JsonSerializer.ReadFromSpan[TValue](ReadOnlySpan`1 json, JsonTypeInfo`1 jsonTypeInfo)
   at System.Text.Json.JsonSerializer.Deserialize[TValue](String json, JsonSerializerOptions options)
   at Program.<Main>$(String[] args) in C:\Users\eerhardt\source\repos\ConsoleApp115\ConsoleApp115\Program.cs:line 27
----

Regression?

No

Known Workarounds

No response

Configuration

No response

Other information

No response

Copy link
Contributor

Tagging subscribers to this area: @dotnet/area-system-text-json, @gregsdennis
See info in area-owners.md if you want to be subscribed.

@dotnet-policy-service dotnet-policy-service bot added the untriaged New issue has not been triaged by the area owner label Apr 2, 2024
eerhardt referenced this issue in eiriktsarpalis/stj-schema-mapper Apr 2, 2024
@eiriktsarpalis
Copy link
Member

The built-in converter for TimeSpan is intentionally blocking out non-canonical representations. This discussion in the implementation PR should provide more context as to why we went for that route. TL;DR it was found that the TimeSpan parser was too permissive sometimes resulting in surprising results. Because the converter is capable of round tripping formats generated by TimeSpan.ToString() we decided to go ahead with this more restrictive version, and potentially relaxing it in the future provided there is demand.

@eiriktsarpalis eiriktsarpalis added enhancement Product code improvement that does NOT require public API changes/additions and removed untriaged New issue has not been triaged by the area owner labels Apr 2, 2024
@eiriktsarpalis eiriktsarpalis added this to the Future milestone Apr 2, 2024
@eerhardt
Copy link
Member Author

eerhardt commented Apr 2, 2024

TL;DR it was found that the TimeSpan parser was too permissive sometimes resulting in surprising results.

I can totally understand restricting to the 'c' format to eliminate the surprising results, that makes sense. The part that I think should be fixed is adding further restrictions from the 'c' format. Basically if Utf8Parser.TryParse(timeSpanStringUtf8, out timeSpan, out _, standardFormat: 'c'); supports the format, so should JsonSerializer.

@danielmarbach
Copy link
Contributor

danielmarbach commented May 3, 2024

We ran into a similar issue. Newtonsoft.Json supports both the 'c' format and the 'g' format (short version) but the built-in TimeSpanConverter restricts the minimum length to be '8' even though Utf8Parser.TryParse(timeSpanStringUtf8, out timeSpan, out _, standardFormat: 'c') would accept with pleasure and parse also the shorter 7 digits version.

Example

Newtonsoft.Json accepts 0:00:02 and 00:00:02 while the System.Text.Json converter only accepts 00:00:02

To not break wire compatibility, we had to implement a custom TimeSpanConverter that is an almost 1:1 copy of the .NET version but allows starting with 7 digits (shortest format).

@eiriktsarpalis
Copy link
Member

Basically if Utf8Parser.TryParse(timeSpanStringUtf8, out timeSpan, out _, standardFormat: 'c'); supports the format, so should JsonSerializer.

The built-in converter does delegate to that routine when parsing:

bool result = Utf8Parser.TryParse(source, out TimeSpan tmpValue, out int bytesConsumed, 'c');

Modulo rejecting leading whitespace the two should be identical.

@danielmarbach
Copy link
Contributor

Except this check

if (!JsonHelpers.IsInRangeInclusive(reader.ValueLength, MinimumTimeSpanFormatLength, MaximumEscapedTimeSpanFormatLength))
prevents things

@eiriktsarpalis
Copy link
Member

Can you share examples of valid inputs that are caught by this particular check? This is there simply to avoid allocating an intermediate buffer (and multiplies the maximum expected size by a factor of 6 to account for potential escaped values)

@danielmarbach
Copy link
Contributor

Example

Newtonsoft.Json accepts 0:00:02 and 00:00:02 while the System.Text.Json converter only accepts 00:00:02

See #100538 (comment)

@danielmarbach
Copy link
Contributor

danielmarbach commented May 9, 2024

The following program demonstrates things

using System;
using JsonSerializer = System.Text.Json.JsonSerializer;
using Newtonsoft.Json;
					
public class Program
{
	public static void Main()
	{
		var timespan = TimeSpan.FromSeconds(2);
		var toSerialize = new ToSerialize() { SomeTimeSpan = timespan };
		
		Console.WriteLine(JsonConvert.DeserializeObject<ToSerialize>(@"{ ""SomeTimeSpan"": ""0:00:02"" }"));
		Console.WriteLine(JsonConvert.DeserializeObject<ToSerialize>(@"{ ""SomeTimeSpan"": ""00:00:02"" }"));
		
		Console.WriteLine(JsonSerializer.Deserialize<ToSerialize>(@"{ ""SomeTimeSpan"": ""0:00:02"" }"));
	}
	
	class ToSerialize {
	  public TimeSpan SomeTimeSpan { get; set; }	
	}
}

Console.WriteLine(JsonSerializer.Deserialize<ToSerialize>(@"{ ""SomeTimeSpan"": ""0:00:02"" }"));

fails with

Unhandled exception. System.Text.Json.JsonException: The JSON value could not be converted to System.TimeSpan. Path: $.SomeTimeSpan | LineNumber: 0 | BytePositionInLine: 27.
 ---> System.FormatException: The JSON value is not in a supported TimeSpan format.
   at System.Text.Json.ThrowHelper.ThrowFormatException(DataType dataType)
   at System.Text.Json.Serialization.Converters.TimeSpanConverter.ReadCore(Utf8JsonReader& reader)
   at System.Text.Json.Serialization.Metadata.JsonPropertyInfo`1.ReadJsonAndSetMember(Object obj, ReadStack& state, Utf8JsonReader& reader)
   at System.Text.Json.Serialization.Converters.ObjectDefaultConverter`1.OnTryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
   at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value, Boolean& isPopulatedValue)
   at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader& reader, JsonSerializerOptions options, ReadStack& state)
   --- End of inner exception stack trace ---
   at System.Text.Json.ThrowHelper.ReThrowWithPath(ReadStack& state, Utf8JsonReader& reader, Exception ex)
   at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader& reader, JsonSerializerOptions options, ReadStack& state)
   at System.Text.Json.JsonSerializer.ReadFromSpan[TValue](ReadOnlySpan`1 utf8Json, JsonTypeInfo`1 jsonTypeInfo, Nullable`1 actualByteCount)
   at System.Text.Json.JsonSerializer.ReadFromSpan[TValue](ReadOnlySpan`1 json, JsonTypeInfo`1 jsonTypeInfo)

while

Utf8Parser.TryParse(Encoding.UTF8.GetBytes("0:00:02"), out TimeSpan parsedTimeSpan, out _, standardFormat: 'c')

would return true

@eiriktsarpalis
Copy link
Member

Example

Newtonsoft.Json accepts 0:00:02 and 00:00:02 while the System.Text.Json converter only accepts 00:00:02

Right, but that input would be rejected on account of it not satisfying the 'c' format, not because the input is too long.

@danielmarbach
Copy link
Contributor

Utf8Parser.TryParse(Encoding.UTF8.GetBytes("0:00:02"), out TimeSpan parsedTimeSpan, out _, standardFormat: 'c')

returns true

@eiriktsarpalis
Copy link
Member

I see, so presumably it gets rejected because the input is too short? I think we should address that.

@eiriktsarpalis
Copy link
Member

Happy to accept a PR that addresses this by the way, it's unlikely we would be able to prioritize this any time soon.

@danielmarbach
Copy link
Contributor

I see, so presumably it gets rejected because the input is too short? I think we should address that.

That's my understanding but I haven't spent enough time to be 100% sure

@danielmarbach
Copy link
Contributor

Happy to accept a PR that addresses this by the way, it's unlikely we would be able to prioritize this any time soon.

I see what I can do

@danielmarbach
Copy link
Contributor

@eiriktsarpalis Here we go #102091

// cc @eerhardt

danielmarbach added a commit to danielmarbach/runtime that referenced this issue May 11, 2024
danielmarbach added a commit to danielmarbach/runtime that referenced this issue May 11, 2024
danielmarbach added a commit to danielmarbach/runtime that referenced this issue May 11, 2024
danielmarbach added a commit to danielmarbach/runtime that referenced this issue May 12, 2024
danielmarbach added a commit to danielmarbach/runtime that referenced this issue May 12, 2024
eiriktsarpalis pushed a commit that referenced this issue May 13, 2024
…d be accepted by Utf8Parser.TryParse (#102091)

Fix #100538

Co-authored-by: Daniel Marbach <danielmarbach@users.noreply.github.com>
@danielmarbach
Copy link
Contributor

Will the fix be backported?

@eiriktsarpalis
Copy link
Member

Unfortunately, no. Only high-impact bugs and security fixes get backported.

Ruihan-Yin pushed a commit to Ruihan-Yin/runtime that referenced this issue May 30, 2024
…d be accepted by Utf8Parser.TryParse (dotnet#102091)

Fix dotnet#100538

Co-authored-by: Daniel Marbach <danielmarbach@users.noreply.github.com>
@github-actions github-actions bot locked and limited conversation to collaborators Jun 14, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-System.Text.Json enhancement Product code improvement that does NOT require public API changes/additions
Projects
None yet
3 participants