From c933616ccf14c4872c41f5a152e1f8a757df5823 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sun, 29 Mar 2026 09:10:45 -0300 Subject: [PATCH] Apply UnknownDerivedTypeHandling.Fail to tag-based polymorphism Previously, UnknownDerivedTypeHandling.Fail only applied to property-based discriminators. Unknown tags in tag-based polymorphism were silently ignored, falling through to the base type. This made it impossible to detect configuration errors when using tag-based type discrimination. This change applies the same Fail/FallBackToBase logic to the tag lookup path in both the reflection-based converter and the source generator. A new ThrowUnknownTypeTag helper is added to YamlThrowHelper for consistent error messages that include the unrecognized tag value. --- .../YamlSerializerContextGenerator.cs | 13 + .../YamlUnknownTagHandlingTests.cs | 275 ++++++++++++++++++ .../Converters/YamlObjectConverter.cs | 8 + .../Serialization/YamlThrowHelper.cs | 4 + 4 files changed, 300 insertions(+) create mode 100644 src/SharpYaml.Tests/Serialization/YamlUnknownTagHandlingTests.cs diff --git a/src/SharpYaml.SourceGenerator/YamlSerializerContextGenerator.cs b/src/SharpYaml.SourceGenerator/YamlSerializerContextGenerator.cs index 5d85ad39..d6f6100a 100644 --- a/src/SharpYaml.SourceGenerator/YamlSerializerContextGenerator.cs +++ b/src/SharpYaml.SourceGenerator/YamlSerializerContextGenerator.cs @@ -3316,6 +3316,19 @@ private static void EmitReadValue( builder.Append(" return (").Append(typeName).Append(")ReadValue").Append(derivedIndex).AppendLine("(bufferedReader)!;"); builder.AppendLine(" }"); } + + // When tag is present but unrecognized, try default type before failing + if (polymorphism.DefaultDerivedType is not null && indexByType.TryGetValue(polymorphism.DefaultDerivedType, out var defaultIndexForUnknownTag)) + { + builder.Append(" return (").Append(typeName).Append(")ReadValue").Append(defaultIndexForUnknownTag).AppendLine("(bufferedReader)!;"); + } + else + { + builder.AppendLine(" if (unknownDerivedTypeHandling == global::SharpYaml.YamlUnknownDerivedTypeHandling.Fail)"); + builder.AppendLine(" {"); + builder.Append(" throw global::SharpYaml.Serialization.YamlThrowHelper.ThrowUnknownTypeTag(bufferedReader, rootTag, typeof(").Append(typeName).AppendLine("));"); + builder.AppendLine(" }"); + } builder.AppendLine(" }"); // Fallback: use default derived type if available diff --git a/src/SharpYaml.Tests/Serialization/YamlUnknownTagHandlingTests.cs b/src/SharpYaml.Tests/Serialization/YamlUnknownTagHandlingTests.cs new file mode 100644 index 00000000..55f315ad --- /dev/null +++ b/src/SharpYaml.Tests/Serialization/YamlUnknownTagHandlingTests.cs @@ -0,0 +1,275 @@ +#nullable enable + +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SharpYaml.Serialization; + +namespace SharpYaml.Tests.Serialization; + +[TestClass] +public class YamlUnknownTagHandlingTests +{ + // ---- Model types ---- + + [YamlPolymorphic(DiscriminatorStyle = YamlTypeDiscriminatorStyle.Tag)] + [YamlDerivedType(typeof(AttributeDog), Tag = "!dog")] + [YamlDerivedType(typeof(AttributeCat), Tag = "!cat")] + private class AttributeAnimal + { + public string Name { get; set; } = string.Empty; + } + + private sealed class AttributeDog : AttributeAnimal + { + public int BarkVolume { get; set; } + } + + private sealed class AttributeCat : AttributeAnimal + { + public bool Indoor { get; set; } + } + + [YamlPolymorphic(DiscriminatorStyle = YamlTypeDiscriminatorStyle.Tag, UnknownDerivedTypeHandling = YamlUnknownDerivedTypeHandling.FallBackToBase)] + [YamlDerivedType(typeof(FallbackDog), Tag = "!dog")] + private class FallbackAnimal + { + public string Name { get; set; } = string.Empty; + } + + private sealed class FallbackDog : FallbackAnimal + { + public int BarkVolume { get; set; } + } + + private abstract class RuntimeAnimal + { + public string Name { get; set; } = string.Empty; + } + + private sealed class RuntimeDog : RuntimeAnimal + { + public int BarkVolume { get; set; } + } + + private sealed class RuntimeCat : RuntimeAnimal + { + public bool Indoor { get; set; } + } + + // ---- Attribute-based: unknown tag with default (Fail) handling ---- + + [TestMethod] + public void UnknownTagFailsByDefaultWithAttributes() + { + var yaml = "!lizard\nName: Gecko\n"; + var ex = Assert.Throws( + () => YamlSerializer.Deserialize(yaml)); + StringAssert.Contains(ex.Message, "!lizard"); + } + + [TestMethod] + public void KnownTagWorksWithAttributes() + { + var yaml = "!dog\nName: Rex\nBarkVolume: 5\n"; + var value = YamlSerializer.Deserialize(yaml); + + Assert.IsNotNull(value); + Assert.IsInstanceOfType(value); + Assert.AreEqual("Rex", value.Name); + Assert.AreEqual(5, ((AttributeDog)value).BarkVolume); + } + + [TestMethod] + public void NoTagDeserializesToBaseTypeWithAttributes() + { + var yaml = "Name: Plain\n"; + var value = YamlSerializer.Deserialize(yaml); + + Assert.IsNotNull(value); + Assert.IsInstanceOfType(value); + Assert.AreEqual("Plain", value.Name); + } + + // ---- Attribute-based: FallBackToBase ---- + + [TestMethod] + public void UnknownTagFallsBackToBaseWhenConfigured() + { + var yaml = "!lizard\nName: Gecko\n"; + var value = YamlSerializer.Deserialize(yaml); + + Assert.IsNotNull(value); + Assert.IsInstanceOfType(value); + Assert.AreEqual("Gecko", value.Name); + } + + // ---- Options-level: Fail ---- + + [TestMethod] + public void UnknownTagFailsWithOptionsLevelFail() + { + var options = new YamlSerializerOptions + { + PolymorphismOptions = new YamlPolymorphismOptions + { + DiscriminatorStyle = YamlTypeDiscriminatorStyle.Tag, + UnknownDerivedTypeHandling = YamlUnknownDerivedTypeHandling.Fail, + DerivedTypeMappings = + { + [typeof(RuntimeAnimal)] = new List + { + new YamlDerivedType(typeof(RuntimeDog), "dog") { Tag = "!dog" }, + new YamlDerivedType(typeof(RuntimeCat), "cat") { Tag = "!cat" }, + } + } + } + }; + + var yaml = "!parrot\nName: Polly\n"; + var ex = Assert.Throws( + () => YamlSerializer.Deserialize(yaml, options)); + StringAssert.Contains(ex.Message, "!parrot"); + } + + [TestMethod] + public void KnownTagWorksWithRuntime() + { + var options = new YamlSerializerOptions + { + PolymorphismOptions = new YamlPolymorphismOptions + { + DiscriminatorStyle = YamlTypeDiscriminatorStyle.Tag, + DerivedTypeMappings = + { + [typeof(RuntimeAnimal)] = new List + { + new YamlDerivedType(typeof(RuntimeDog), "dog") { Tag = "!dog" }, + } + } + } + }; + + var yaml = "!dog\nName: Rex\nBarkVolume: 3\n"; + var value = YamlSerializer.Deserialize(yaml, options); + + Assert.IsNotNull(value); + Assert.IsInstanceOfType(value); + Assert.AreEqual("Rex", value.Name); + Assert.AreEqual(3, ((RuntimeDog)value).BarkVolume); + } + + // ---- Options-level: FallBackToBase ---- + + private class ConcreteAnimal + { + public string Name { get; set; } = string.Empty; + } + + private sealed class ConcreteDog : ConcreteAnimal + { + public int BarkVolume { get; set; } + } + + [TestMethod] + public void UnknownTagFallsBackToBaseWithOptions() + { + var options = new YamlSerializerOptions + { + PolymorphismOptions = new YamlPolymorphismOptions + { + DiscriminatorStyle = YamlTypeDiscriminatorStyle.Tag, + UnknownDerivedTypeHandling = YamlUnknownDerivedTypeHandling.FallBackToBase, + DerivedTypeMappings = + { + [typeof(ConcreteAnimal)] = new List + { + new YamlDerivedType(typeof(ConcreteDog), "dog") { Tag = "!dog" }, + } + } + } + }; + + var yaml = "!parrot\nName: Polly\n"; + var value = YamlSerializer.Deserialize(yaml, options); + + Assert.IsNotNull(value); + Assert.IsInstanceOfType(value); + Assert.AreEqual("Polly", value.Name); + } + + // ---- Attribute-level override takes precedence over options ---- + + [TestMethod] + public void AttributeUnknownHandlingOverridesOptions() + { + // FallbackAnimal has FallBackToBase in attribute; options say Fail — attribute wins + var options = new YamlSerializerOptions + { + PolymorphismOptions = new YamlPolymorphismOptions + { + UnknownDerivedTypeHandling = YamlUnknownDerivedTypeHandling.Fail, + } + }; + + var yaml = "!lizard\nName: Gecko\n"; + var value = YamlSerializer.Deserialize(yaml, options); + + Assert.IsNotNull(value); + Assert.IsInstanceOfType(value); + Assert.AreEqual("Gecko", value.Name); + } + + // ---- Dictionary of polymorphic values with unknown tags ---- + + [TestMethod] + public void UnknownTagInDictionaryValueFails() + { + var options = new YamlSerializerOptions + { + PolymorphismOptions = new YamlPolymorphismOptions + { + DiscriminatorStyle = YamlTypeDiscriminatorStyle.Tag, + UnknownDerivedTypeHandling = YamlUnknownDerivedTypeHandling.Fail, + DerivedTypeMappings = + { + [typeof(RuntimeAnimal)] = new List + { + new YamlDerivedType(typeof(RuntimeDog), "dog") { Tag = "!dog" }, + } + } + } + }; + + var yaml = "rex: !dog\n Name: Rex\n BarkVolume: 3\npolly: !parrot\n Name: Polly\n"; + Assert.Throws( + () => YamlSerializer.Deserialize>(yaml, options)); + } + + // ---- Tag-only entries (no discriminator) with unknown tags ---- + + [TestMethod] + public void UnknownTagFailsWithTagOnlyEntries() + { + var options = new YamlSerializerOptions + { + PolymorphismOptions = new YamlPolymorphismOptions + { + DiscriminatorStyle = YamlTypeDiscriminatorStyle.Tag, + UnknownDerivedTypeHandling = YamlUnknownDerivedTypeHandling.Fail, + DerivedTypeMappings = + { + [typeof(RuntimeAnimal)] = new List + { + new YamlDerivedType(typeof(RuntimeDog)) { Tag = "!dog" }, + new YamlDerivedType(typeof(RuntimeCat)) { Tag = "!cat" }, + } + } + } + }; + + var yaml = "!fish\nName: Nemo\n"; + var ex = Assert.Throws( + () => YamlSerializer.Deserialize(yaml, options)); + StringAssert.Contains(ex.Message, "!fish"); + } +} diff --git a/src/SharpYaml/Serialization/Converters/YamlObjectConverter.cs b/src/SharpYaml/Serialization/Converters/YamlObjectConverter.cs index 8c76b1cf..4949006a 100644 --- a/src/SharpYaml/Serialization/Converters/YamlObjectConverter.cs +++ b/src/SharpYaml/Serialization/Converters/YamlObjectConverter.cs @@ -1350,6 +1350,14 @@ private void WriteObjectCore(YamlWriter writer, object value, Contract contract) { targetType = derivedFromTag; } + else if (polymorphism.DefaultDerivedType is not null) + { + targetType = polymorphism.DefaultDerivedType; + } + else if (polymorphism.UnknownDerivedTypeHandling == YamlUnknownDerivedTypeHandling.Fail) + { + throw YamlThrowHelper.ThrowUnknownTypeTag(reader, rootTag, typeof(T)); + } } targetType ??= polymorphism.DefaultDerivedType ?? typeof(T); diff --git a/src/SharpYaml/Serialization/YamlThrowHelper.cs b/src/SharpYaml/Serialization/YamlThrowHelper.cs index f2bf93cf..94e612e1 100644 --- a/src/SharpYaml/Serialization/YamlThrowHelper.cs +++ b/src/SharpYaml/Serialization/YamlThrowHelper.cs @@ -52,6 +52,10 @@ public static YamlException ThrowDuplicateMappingKey(YamlReader reader, string k public static YamlException ThrowUnknownTypeDiscriminator(YamlReader reader, string? discriminatorValue, Type baseType) => new(reader.SourceName, reader.Start, reader.End, $"Unknown type discriminator '{discriminatorValue}' for '{baseType}'."); + /// Throws an exception for unknown Type Tag. + public static YamlException ThrowUnknownTypeTag(YamlReader reader, string? tag, Type baseType) + => new(reader.SourceName, reader.Start, reader.End, $"Unknown type tag '{tag}' for '{baseType}'."); + /// Throws an exception for abstract Type Without Discriminator. public static YamlException ThrowAbstractTypeWithoutDiscriminator(YamlReader reader, Type type) => new(reader.SourceName, reader.Start, reader.End, $"Cannot deserialize abstract type '{type}' without a known derived type discriminator.");