Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 171 additions & 6 deletions src/Abstractions/Converters/JsonDataConverter.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Collections;
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The using directive for System.Collections appears to be unused. The code only uses ICollection, IEnumerable, IList, List, Dictionary<TKey, TValue>, and IDictionary<TKey, TValue>, all of which are in System.Collections.Generic (already imported on line 5). Consider removing this unused using directive.

Suggested change
using System.Collections;

Copilot uses AI. Check for mistakes.
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Microsoft.DurableTask.Converters;

Expand All @@ -11,19 +14,37 @@
public class JsonDataConverter : DataConverter
{
// WARNING: Changing default serialization options could potentially be breaking for in-flight orchestrations.
static readonly JsonSerializerOptions DefaultOptions = new()
{
IncludeFields = true,
};
static readonly JsonSerializerOptions DefaultOptions = CreateDefaultOptions();

readonly JsonSerializerOptions? options;

/// <summary>
/// Initializes a new instance of the <see cref="JsonDataConverter"/> class.
/// </summary>
/// <param name="options">The serializer options.</param>
/// <param name="options">The serializer options. If null, default options with type metadata preservation are used.</param>
public JsonDataConverter(JsonSerializerOptions? options = null)
{
if (options != null)
{
// Ensure the ObjectWithTypeMetadataJsonConverterFactory is present in custom options
// Check if it's already there to avoid duplicates
bool hasConverter = false;
foreach (JsonConverter converter in options.Converters)
{
if (converter is ObjectWithTypeMetadataJsonConverterFactory)
{
hasConverter = true;
break;
}
}
Comment on lines +32 to +39
Comment on lines +32 to +39
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.

if (!hasConverter)
{
// Add at the beginning to ensure it handles object types first
options.Converters.Insert(0, new ObjectWithTypeMetadataJsonConverterFactory());
}
}

this.options = options ?? DefaultOptions;
}

Expand All @@ -32,8 +53,24 @@
/// </summary>
public static JsonDataConverter Default { get; } = new JsonDataConverter();

static JsonSerializerOptions CreateDefaultOptions()
{
JsonSerializerOptions options = new()
{
IncludeFields = true,
};

// Add converter factory for preserving type information when deserializing to object type
// and Dictionary<string, object> values. This must be added early to handle object types
// before default JsonElement conversion.
// See issue #430: https://github.com/microsoft/durabletask-dotnet/issues/430
options.Converters.Insert(0, new ObjectWithTypeMetadataJsonConverterFactory());

return options;
}

/// <inheritdoc/>
public override string? Serialize(object? value)

Check warning on line 73 in src/Abstractions/Converters/JsonDataConverter.cs

View workflow job for this annotation

GitHub Actions / build

{
return value != null ? JsonSerializer.Serialize(value, this.options) : null;
}
Expand All @@ -41,6 +78,134 @@
/// <inheritdoc/>
public override object? Deserialize(string? data, Type targetType)
{
return data != null ? JsonSerializer.Deserialize(data, targetType, this.options) : null;
if (data == null)
{
return null;
}

// Special case: If target type is JsonElement, we should unwrap any type metadata
// and return the actual JSON content, not the wrapped structure
if (targetType == typeof(JsonElement))
{
using (JsonDocument doc = JsonDocument.Parse(data))
{
JsonElement root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("$type", out JsonElement typeElement) &&
root.TryGetProperty("$value", out JsonElement valueElement))
{
// Unwrap and return the actual value as JsonElement
return valueElement.Clone();
}
}
}
Comment on lines +88 to +101
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JsonDocument is being parsed twice in the Deserialize method - once here for JsonElement unwrapping (lines 88-101) and again immediately after (lines 105-206) for general unwrapping logic. This creates unnecessary overhead as JsonDocument.Parse is called twice on the same data. Consider consolidating these checks into a single parse operation to improve performance.

Copilot uses AI. Check for mistakes.

// Check if the JSON is a wrapped object (has $type and $value)
// This handles both array wrappers and object wrappers
using (JsonDocument doc = JsonDocument.Parse(data))
{
JsonElement root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("$type", out JsonElement typeElement) &&
root.TryGetProperty("$value", out JsonElement valueElement))
{
// This is a wrapped object - check what type it is
string typeName = typeElement.GetString()!;
Type? wrappedType = Type.GetType(typeName, throwOnError: false);
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Type.GetType with AssemblyQualifiedName from untrusted JSON input can pose security risks. An attacker could provide a malicious type name that loads unexpected assemblies or triggers unintended code execution during type loading. Consider implementing a type allowlist or validating that loaded types are from trusted assemblies only. This is especially important since this converter is used for deserializing orchestration and activity inputs that may come from external sources.

Copilot uses AI. Check for mistakes.

// If the wrapped type matches the target type exactly, unwrap and deserialize
if (wrappedType != null && wrappedType == targetType)
{
// The wrapped type matches the target type - unwrap and deserialize
string unwrappedJson = valueElement.GetRawText();
return JsonSerializer.Deserialize(unwrappedJson, targetType, this.options);
}

// Special case: If wrapped type is an array and target type is also an array,
// try to deserialize the entire array (for cases like int[] -> int[])
if (wrappedType != null && wrappedType.IsArray && targetType.IsArray)
{
string unwrappedJson = valueElement.GetRawText();
return JsonSerializer.Deserialize(unwrappedJson, targetType, this.options);
}

Check warning on line 131 in src/Abstractions/Converters/JsonDataConverter.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Check warning on line 131 in src/Abstractions/Converters/JsonDataConverter.cs

View workflow job for this annotation

GitHub Actions / build

// Special case: If wrapped type is a collection (like List<T>) and target type is an array (like T[]),
// deserialize the $value JSON directly as the target array type
// This works because List<T> and T[] have the same JSON representation: [item1, item2, ...]
if (wrappedType != null && targetType.IsArray && valueElement.ValueKind == JsonValueKind.Array)
{
// Check if wrapped type is a generic collection that implements IEnumerable<T>
if (wrappedType.IsGenericType)
{
Type genericTypeDef = wrappedType.GetGenericTypeDefinition();
if (genericTypeDef == typeof(List<>) ||

Check warning on line 141 in src/Abstractions/Converters/JsonDataConverter.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Check warning on line 141 in src/Abstractions/Converters/JsonDataConverter.cs

View workflow job for this annotation

GitHub Actions / build

genericTypeDef == typeof(IList<>) ||
genericTypeDef == typeof(ICollection<>) ||
genericTypeDef == typeof(IEnumerable<>))
{
Type[] genericArgs = wrappedType.GetGenericArguments();
if (genericArgs.Length == 1)
{
Type elementType = genericArgs[0];
Type targetElementType = targetType.GetElementType()!;

Check warning on line 151 in src/Abstractions/Converters/JsonDataConverter.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Check warning on line 151 in src/Abstractions/Converters/JsonDataConverter.cs

View workflow job for this annotation

GitHub Actions / build

// If the element types match, we can deserialize directly
if (elementType == targetElementType)
{
string unwrappedJson = valueElement.GetRawText();
return JsonSerializer.Deserialize(unwrappedJson, targetType, this.options);
}
}
}
}
Comment on lines +135 to +160
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 'if' statements can be combined.

Suggested change
if (wrappedType != null && targetType.IsArray && valueElement.ValueKind == JsonValueKind.Array)
{
// Check if wrapped type is a generic collection that implements IEnumerable<T>
if (wrappedType.IsGenericType)
{
Type genericTypeDef = wrappedType.GetGenericTypeDefinition();
if (genericTypeDef == typeof(List<>) ||
genericTypeDef == typeof(IList<>) ||
genericTypeDef == typeof(ICollection<>) ||
genericTypeDef == typeof(IEnumerable<>))
{
Type[] genericArgs = wrappedType.GetGenericArguments();
if (genericArgs.Length == 1)
{
Type elementType = genericArgs[0];
Type targetElementType = targetType.GetElementType()!;
// If the element types match, we can deserialize directly
if (elementType == targetElementType)
{
string unwrappedJson = valueElement.GetRawText();
return JsonSerializer.Deserialize(unwrappedJson, targetType, this.options);
}
}
}
}
if (
wrappedType != null &&
targetType.IsArray &&
valueElement.ValueKind == JsonValueKind.Array &&
wrappedType.IsGenericType &&
(
wrappedType.GetGenericTypeDefinition() == typeof(List<>) ||
wrappedType.GetGenericTypeDefinition() == typeof(IList<>) ||
wrappedType.GetGenericTypeDefinition() == typeof(ICollection<>) ||
wrappedType.GetGenericTypeDefinition() == typeof(IEnumerable<>)
) &&
wrappedType.GetGenericArguments().Length == 1 &&
wrappedType.GetGenericArguments()[0] == targetType.GetElementType()
)
{
string unwrappedJson = valueElement.GetRawText();
return JsonSerializer.Deserialize(unwrappedJson, targetType, this.options);

Copilot uses AI. Check for mistakes.
}
Comment on lines +135 to +161

Check warning on line 162 in src/Abstractions/Converters/JsonDataConverter.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Check warning on line 162 in src/Abstractions/Converters/JsonDataConverter.cs

View workflow job for this annotation

GitHub Actions / build

// Special case: If wrapped type is an array and target type is NOT an array,
// check if the array contains a single wrapped element that matches the target type
if (wrappedType != null && wrappedType.IsArray && !targetType.IsArray)
{
if (valueElement.ValueKind == JsonValueKind.Array && valueElement.GetArrayLength() > 0)
{
// Get the first element of the array
JsonElement firstElement = valueElement[0];
string firstElementJson = firstElement.GetRawText();

Check warning on line 172 in src/Abstractions/Converters/JsonDataConverter.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Check warning on line 172 in src/Abstractions/Converters/JsonDataConverter.cs

View workflow job for this annotation

GitHub Actions / build

// Check if the first element is also wrapped
using (JsonDocument innerDoc = JsonDocument.Parse(firstElementJson))
{
JsonElement innerRoot = innerDoc.RootElement;
if (innerRoot.ValueKind == JsonValueKind.Object &&
innerRoot.TryGetProperty("$type", out JsonElement innerTypeElement) &&
innerRoot.TryGetProperty("$value", out JsonElement innerValueElement))
{
string innerTypeName = innerTypeElement.GetString()!;
Type? innerWrappedType = Type.GetType(innerTypeName, throwOnError: false);
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Type.GetType with type names from JSON input can pose security risks. An attacker could provide a malicious type name that loads unexpected assemblies or triggers unintended code execution during type loading. Consider implementing a type allowlist or validating that loaded types are from trusted assemblies only.

Copilot uses AI. Check for mistakes.

Check warning on line 183 in src/Abstractions/Converters/JsonDataConverter.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Check warning on line 183 in src/Abstractions/Converters/JsonDataConverter.cs

View workflow job for this annotation

GitHub Actions / build

if (innerWrappedType != null && innerWrappedType == targetType)
{
// Unwrap the inner object
string unwrappedJson = innerValueElement.GetRawText();
return JsonSerializer.Deserialize(unwrappedJson, targetType, this.options);
}
}
}

Check warning on line 192 in src/Abstractions/Converters/JsonDataConverter.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

// First element is not wrapped, try to deserialize it directly
// This handles cases where the array contains a single unwrapped element
try
{
return JsonSerializer.Deserialize(firstElementJson, targetType, this.options);
}
catch
{
// If deserialization fails, fall through to normal deserialization
}
Comment on lines +199 to +202
Comment on lines +199 to +202
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The empty catch block silently swallows all exceptions without logging or handling them. This makes debugging difficult and can hide important errors. At minimum, the exception should be logged, or the catch should be more specific about which exceptions are expected (e.g., JsonException for malformed JSON).

Copilot uses AI. Check for mistakes.
}
}
Comment on lines +165 to +204
Comment on lines +163 to +204
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for checking if wrapped type is an array and target is NOT an array (lines 163-204) is complex and appears to be attempting to handle a very specific edge case. The comment mentions "if the array contains a single wrapped element" but the code continues even when the array has multiple elements (only checking the first). This logic seems overly complex for the value it provides. Consider simplifying this or documenting why this specific case needs to be handled.

Copilot uses AI. Check for mistakes.
Comment on lines +165 to +204
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 'if' statements can be combined.

Copilot uses AI. Check for mistakes.
}
}

// Not wrapped or type doesn't match - deserialize normally
return JsonSerializer.Deserialize(data, targetType, this.options);
}
}
Loading
Loading