Skip to content
Closed
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -321,3 +321,4 @@ _deps
*-prefix/

**/.nx/*
.nuget/
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,11 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions
{
if (!prop.CanRead) continue;
if (prop.GetIndexParameters().Length > 0) continue; // Skip indexers
if (prop.GetCustomAttribute<JsonIgnoreAttribute>() is JsonIgnoreAttribute ignore && ignore.Condition == JsonIgnoreCondition.Always) continue;
if (prop.GetCustomAttribute<JsonExtensionDataAttribute>() != null) continue;

var ignoreAttr = prop.GetCustomAttribute<JsonIgnoreAttribute>();
if (ignoreAttr != null && ignoreAttr.Condition == JsonIgnoreCondition.Always) continue;

string jsonName;
var nameAttr = prop.GetCustomAttribute<JsonPropertyNameAttribute>();
if (nameAttr != null)
Expand All @@ -185,15 +187,34 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions
jsonName = prop.Name;
}

if (string.IsNullOrEmpty(jsonName)) continue;

var propValue = prop.GetValue(value);

// Handle null suppression
if (propValue == null && options.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingNull) continue;
var propType = prop.PropertyType;

if (string.IsNullOrEmpty(jsonName)) continue;
// Determine the effective ignore condition: per-property attribute overrides the global option.
var effectiveCondition = (ignoreAttr != null)
? ignoreAttr.Condition
: options.DefaultIgnoreCondition;

// Apply the effective ignore condition
if (effectiveCondition == JsonIgnoreCondition.WhenWritingNull && propValue == null) continue;
if (effectiveCondition == JsonIgnoreCondition.WhenWritingDefault)
{
if (propValue == null) continue;
// For value types compare against the type's default (e.g. false for bool, 0 for enum).
// Activator.CreateInstance always returns a non-null boxed value for value types so
// the null-conditional guard here is purely defensive.
if (propType.IsValueType)
{
var underlyingType = Nullable.GetUnderlyingType(propType) ?? propType;
var typeDefault = Activator.CreateInstance(underlyingType);
if (typeDefault == null || propValue.Equals(typeDefault)) continue;
}
}

writer.WritePropertyName(jsonName);
JsonSerializer.Serialize(writer, propValue, prop.PropertyType, options);
JsonSerializer.Serialize(writer, propValue, propType, options);
}

// Write extension data
Expand Down
8 changes: 7 additions & 1 deletion source/dotnet/Library/AdaptiveCards/AdaptiveElement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,13 @@ public abstract class AdaptiveElement : AdaptiveTypedElement
/// <summary>
/// Indicates whether the element should be visible when the card has been rendered.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
/// <remarks>
/// The spec default is <c>true</c> (visible). Because the .NET type default for <c>bool</c>
/// is <c>false</c>, using <see cref="JsonIgnoreCondition.WhenWritingDefault"/> would suppress
/// <c>false</c> values during serialization — which would then be read back as <c>true</c>
/// (the initialised default) and silently make hidden elements visible. To avoid this roundtrip
/// regression the property is always serialised regardless of its value.
/// </remarks>
[XmlElement]
[DefaultValue(true)]
public bool IsVisible { get; set; } = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,20 @@ public AdaptiveFallbackConverter(List<AdaptiveWarning> warnings, ParseContext pa
/// <summary>
/// State tracking to determine whether we're currently processing a fallback request.
/// </summary>
public static bool IsInFallback = false;
/// <remarks>
/// Marked <c>[ThreadStatic]</c> to avoid race conditions when multiple threads parse
/// cards concurrently. Each thread has its own copy of this flag so that one thread's
/// fallback state cannot corrupt another thread's ID collision detection.
/// </remarks>
[System.ThreadStatic]
private static bool _isInFallback;

/// <inheritdoc cref="_isInFallback"/>
public static bool IsInFallback
{
get => _isInFallback;
set => _isInFallback = value;
}

/// <inheritdoc />
public override AdaptiveFallbackElement Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
Expand Down
4 changes: 2 additions & 2 deletions source/dotnet/Library/AdaptiveCards/SafeJsonHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ namespace AdaptiveCards
/// duplicate keys (which is valid JSON per RFC 8259 but not handled by JsonObject.Create).
/// </summary>
/// <remarks>
/// System.Text.Json's <see cref="JsonObject.Create(JsonElement)"/> throws
/// <see cref="System.ArgumentException"/> when duplicate keys are present.
/// <see cref="System.Text.Json.Nodes.JsonObject"/> throws
/// <see cref="System.ArgumentException"/> when duplicate keys are present in a JsonElement.
/// This helper uses indexer assignment so duplicates silently keep the last value,
/// matching the previous Newtonsoft.Json behavior. A debug warning is emitted
/// when duplicates are detected to help identify malformed payloads.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<GenerateAssemblyInfo Condition="$(Tfs_PackageVersionNumber) != ''">false</GenerateAssemblyInfo>
<IsPackable>false</IsPackable>
</PropertyGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
Expand Down Expand Up @@ -812,5 +813,164 @@ public void Table_DeserializesCorrectly()
Assert.AreEqual(1, cell1.Items.Count);
Assert.AreEqual("Cell 1", (cell1.Items[0] as AdaptiveTextBlock)?.Text);
}

// =====================================================================
// Category 6: Bug-fix Regression Tests
// =====================================================================

/// <summary>
/// Verifies that <see cref="AdaptiveFallbackConverter.IsInFallback"/> does not leak
/// across threads. Before the ThreadStatic fix, concurrent parsing of cards that contain
/// fallback elements could corrupt the shared flag, causing valid cards to throw a
/// spurious ID-collision exception or silently accept real collisions.
/// </summary>
[TestMethod]
public void ConcurrentFallbackParsing_IsThreadSafe()
{
// A card with a fallback element that has the same ID as its parent (allowed per spec).
var json = @"{
""type"": ""AdaptiveCard"",
""version"": ""1.2"",
""body"": [
{
""type"": ""TextBlock"",
""id"": ""shared"",
""text"": ""Primary"",
""fallback"": {
""type"": ""TextBlock"",
""id"": ""shared"",
""text"": ""Fallback""
}
}
]
}";

var exceptions = new ConcurrentBag<Exception>();
var tasks = new Task[10];

for (int i = 0; i < 10; i++)
{
tasks[i] = Task.Run(() =>
{
try
{
for (int j = 0; j < 50; j++)
{
var result = AdaptiveCard.FromJson(json);
Assert.IsNotNull(result.Card);
Assert.AreEqual(1, result.Card.Body.Count);
}
}
catch (Exception ex)
{
exceptions.Add(ex);
}
});
}

Task.WaitAll(tasks);
Assert.AreEqual(0, exceptions.Count,
$"Thread-safety failures: {string.Join("; ", exceptions.Select(e => e.Message))}");
}

/// <summary>
/// Verifies that collection element types (Container, Column, ColumnSet) do not emit
/// properties that have their default values. Before the fix, the
/// <c>AdaptiveCollectionElementConverter</c> wrote every property regardless of
/// <c>[JsonIgnore(Condition = WhenWritingNull/WhenWritingDefault)]</c>, producing
/// verbose (and sometimes <c>null</c>-valued) JSON that violated the spec.
/// </summary>
[TestMethod]
public void CollectionElements_DefaultPropertiesAreNotSerialized()
{
var card = new AdaptiveCard("1.2")
{
Body =
{
new AdaptiveContainer { Items = { new AdaptiveTextBlock("Hello") } },
new AdaptiveColumnSet
{
Columns = { new AdaptiveColumn { Items = { new AdaptiveTextBlock("Col") } } }
}
}
};

var json = card.ToJson();

// These are all default values — they must NOT appear in the output.
Assert.IsFalse(json.Contains("\"separator\""),
"Default 'separator: false' must not be serialized");
Assert.IsFalse(json.Contains("\"bleed\""),
"Default 'bleed: false' must not be serialized");
Assert.IsFalse(json.Contains("\"horizontalAlignment\""),
"Default 'horizontalAlignment: Left' must not be serialized");
Assert.IsFalse(json.Contains("\"verticalContentAlignment\""),
"Default 'verticalContentAlignment: Top' (null-written by enum converter) must not be serialized");

// Non-default values must still round-trip correctly.
var card2 = new AdaptiveCard("1.2")
{
Body =
{
new AdaptiveContainer
{
Bleed = true,
Style = AdaptiveContainerStyle.Emphasis,
Items = { new AdaptiveTextBlock("Hello") }
}
}
};

var json2 = card2.ToJson();
Assert.IsTrue(json2.Contains("\"bleed\""), "Non-default 'bleed: true' must be serialized");
Assert.IsTrue(json2.Contains("emphasis"), "Non-default 'style: emphasis' must be serialized");

var reparsed = AdaptiveCard.FromJson(json2).Card;
var container = reparsed.Body[0] as AdaptiveContainer;
Assert.IsTrue(container.Bleed);
Assert.AreEqual(AdaptiveContainerStyle.Emphasis, container.Style);
}

/// <summary>
/// Verifies that setting <see cref="AdaptiveElement.IsVisible"/> to <c>false</c> is
/// preserved after a serialise-then-parse round-trip. Before the fix, the property used
/// <c>[JsonIgnore(Condition = WhenWritingDefault)]</c> which silently dropped
/// <c>false</c> (the bool type-default) from the JSON, causing hidden elements to
/// reappear as visible after re-parsing.
/// </summary>
[TestMethod]
public void IsVisible_False_RoundtripsCorrectly()
{
var card = new AdaptiveCard("1.0")
{
Body =
{
new AdaptiveTextBlock("Hidden") { IsVisible = false },
new AdaptiveTextBlock("Visible") { IsVisible = true },
new AdaptiveContainer
{
IsVisible = false,
Items = { new AdaptiveTextBlock("Inside hidden container") }
}
}
};

var json = card.ToJson();

// "isVisible": false must be present in the serialized JSON
Assert.IsTrue(json.Contains("\"isVisible\""),
"The isVisible property must always be serialized");
Assert.IsTrue(json.Contains("\"isVisible\":false") || json.Contains("\"isVisible\": false"),
"isVisible: false must be present in the JSON");

var reparsed = AdaptiveCard.FromJson(json).Card;

Assert.IsFalse(((AdaptiveTextBlock)reparsed.Body[0]).IsVisible,
"TextBlock with IsVisible=false must remain hidden after roundtrip");
Assert.IsTrue(((AdaptiveTextBlock)reparsed.Body[1]).IsVisible,
"TextBlock with IsVisible=true must remain visible after roundtrip");
Assert.IsFalse(((AdaptiveContainer)reparsed.Body[2]).IsVisible,
"Container with IsVisible=false must remain hidden after roundtrip");
}
}
}