Skip to content

Commit 9c13310

Browse files
authored
fix: enhance Skip() method to prevent infinite loops during deserialization of nested mappings (#6)
1 parent d1bab84 commit 9c13310

File tree

3 files changed

+287
-12
lines changed

3 files changed

+287
-12
lines changed

src/Yamlify.SourceGenerator/YamlSourceGenerator.cs

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1805,17 +1805,22 @@ private static void GeneratePropertyRead(StringBuilder sb, string propName, ITyp
18051805
if (siblingInfo is not null && !IsDictionary(propType, out _, out _))
18061806
{
18071807
var discriminatorVarName = $"_{siblingInfo.DiscriminatorPropertyName.ToLowerInvariant()}";
1808-
sb.AppendLine($" {varName} = {discriminatorVarName}.ToString() switch");
1808+
sb.AppendLine($" switch ({discriminatorVarName}.ToString())");
18091809
sb.AppendLine(" {");
18101810

18111811
foreach (var (discValue, concreteType) in siblingInfo.Mappings)
18121812
{
18131813
var concreteConverterName = GetConverterName(concreteType);
1814-
sb.AppendLine($" \"{discValue}\" => new {concreteConverterName}().Read(ref reader, options),");
1814+
sb.AppendLine($" case \"{discValue}\":");
1815+
sb.AppendLine($" {varName} = new {concreteConverterName}().Read(ref reader, options);");
1816+
sb.AppendLine(" break;");
18151817
}
18161818

1817-
sb.AppendLine(" _ => null");
1818-
sb.AppendLine(" };");
1819+
sb.AppendLine(" default:");
1820+
sb.AppendLine(" reader.Skip();");
1821+
sb.AppendLine($" {varName} = null;");
1822+
sb.AppendLine(" break;");
1823+
sb.AppendLine(" }");
18191824
return;
18201825
}
18211826

@@ -3155,17 +3160,23 @@ private static void GenerateDictionaryRead(StringBuilder sb, string varName, ITy
31553160
{
31563161
// Use sibling discriminator to determine concrete type for dictionary values
31573162
var discriminatorVarName = $"_{siblingInfo.DiscriminatorPropertyName.ToLowerInvariant()}";
3158-
sb.AppendLine($" {valueTypeStr} value = {discriminatorVarName}.ToString() switch");
3163+
sb.AppendLine($" {valueTypeStr} value;");
3164+
sb.AppendLine($" switch ({discriminatorVarName}.ToString())");
31593165
sb.AppendLine(" {");
31603166

31613167
foreach (var (discValue, concreteType) in siblingInfo.Mappings)
31623168
{
31633169
var concreteConverterName = GetConverterName(concreteType);
3164-
sb.AppendLine($" \"{discValue}\" => new {concreteConverterName}().Read(ref reader, options),");
3170+
sb.AppendLine($" case \"{discValue}\":");
3171+
sb.AppendLine($" value = new {concreteConverterName}().Read(ref reader, options);");
3172+
sb.AppendLine(" break;");
31653173
}
31663174

3167-
sb.AppendLine(" _ => null");
3168-
sb.AppendLine(" };");
3175+
sb.AppendLine(" default:");
3176+
sb.AppendLine(" reader.Skip();");
3177+
sb.AppendLine(" value = null;");
3178+
sb.AppendLine(" break;");
3179+
sb.AppendLine(" }");
31693180
}
31703181
else
31713182
{

src/Yamlify/Reader/Utf8YamlReader.cs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,7 @@ public readonly bool IsNull()
576576

577577
/// <summary>
578578
/// Skips the current token and all its children (for collections).
579+
/// After Skip() returns, the reader is positioned at the token AFTER the skipped content.
579580
/// </summary>
580581
public void Skip()
581582
{
@@ -586,14 +587,20 @@ public void Skip()
586587
{
587588
// Keep reading until we exit the current depth
588589
}
590+
// After the while loop, we're at the end marker (MappingEnd/SequenceEnd).
591+
// Advance past it so the caller doesn't confuse it with their parent's end marker.
592+
Read();
589593
}
590-
else if (_tokenType is YamlTokenType.Scalar)
594+
else if (_tokenType is YamlTokenType.Scalar
595+
or YamlTokenType.MappingEnd
596+
or YamlTokenType.SequenceEnd)
591597
{
592-
// For scalar values (including nulls like ~, null, Null, NULL), just advance to the next token
598+
// For scalar values (including nulls), and end markers that shouldn't be
599+
// at current position, advance to the next token to prevent infinite loops
600+
// when converters call Skip() on unexpected token types
593601
Read();
594602
}
595-
// For other tokens (end markers, etc.), do nothing - they're structural tokens
596-
// that don't need to be skipped
603+
// For other tokens (None, DocumentStart, DocumentEnd), do nothing
597604
}
598605
}
599606

test/Yamlify.Tests/Serialization/InfiniteLoopRegressionTests.cs

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,255 @@ public void DeserializeArrayWithAllNullElements_DoesNotHang()
473473
}
474474

475475
#endregion
476+
477+
#region Skip() Nested Mapping Tests
478+
479+
/// <summary>
480+
/// Model with properties that include nested mappings to test Skip() behavior.
481+
/// </summary>
482+
public class ServiceConfig
483+
{
484+
public string? Name { get; set; }
485+
public DeploymentConfig? Deployment { get; set; }
486+
public string? Namespace { get; set; }
487+
}
488+
489+
public class DeploymentConfig
490+
{
491+
public int Replicas { get; set; }
492+
public ResourceConfig? Resources { get; set; }
493+
}
494+
495+
public class ResourceConfig
496+
{
497+
public string? Cpu { get; set; }
498+
public string? Memory { get; set; }
499+
}
500+
501+
/// <summary>
502+
/// Model with sibling discriminator for testing unknown discriminator Skip().
503+
/// </summary>
504+
public class ServiceWithPlugins
505+
{
506+
public string? Name { get; set; }
507+
public PluginType PluginKind { get; set; }
508+
509+
[YamlSiblingDiscriminator(nameof(PluginKind))]
510+
[YamlDiscriminatorMapping(nameof(PluginType.Metrics), typeof(MetricsPlugin))]
511+
[YamlDiscriminatorMapping(nameof(PluginType.Logging), typeof(LoggingPlugin))]
512+
public PluginBase? Plugin { get; set; }
513+
514+
public string? Description { get; set; }
515+
}
516+
517+
public enum PluginType
518+
{
519+
Metrics,
520+
Logging,
521+
Tracing
522+
}
523+
524+
public abstract class PluginBase
525+
{
526+
}
527+
528+
public class MetricsPlugin : PluginBase
529+
{
530+
public int Port { get; set; }
531+
}
532+
533+
public class LoggingPlugin : PluginBase
534+
{
535+
public string? Level { get; set; }
536+
}
537+
538+
/// <summary>
539+
/// Test: Skip() on a nested mapping should advance past the nested MappingEnd,
540+
/// not the parent's MappingEnd. This was the root cause of the OOM bug.
541+
/// When deserializing a mapping and encountering an unknown property with a nested
542+
/// mapping value, Skip() must consume the entire nested mapping AND advance past
543+
/// its MappingEnd marker, so the parent loop continues correctly.
544+
/// </summary>
545+
[Fact]
546+
public void Deserialize_UnknownPropertyWithNestedMapping_PropertiesAfterStillParsed()
547+
{
548+
var yaml = """
549+
name: my-service
550+
unknown-nested-property:
551+
nested-key1: value1
552+
nested-key2: value2
553+
deeply-nested:
554+
key: value
555+
namespace: production
556+
""";
557+
558+
// The key issue: when unknown-nested-property is skipped, the reader should
559+
// end up AFTER the nested MappingEnd, so "namespace" is still read.
560+
var result = YamlSerializer.Deserialize(yaml, InfiniteLoopTestContext.Default.ServiceConfig);
561+
562+
Assert.NotNull(result);
563+
Assert.Equal("my-service", result.Name);
564+
Assert.Equal("production", result.Namespace); // This was not being set before the fix
565+
}
566+
567+
/// <summary>
568+
/// Test: Multiple unknown properties with nested mappings should all be skipped correctly.
569+
/// </summary>
570+
[Fact]
571+
public void Deserialize_MultipleUnknownPropertiesWithNestedMappings_DoesNotHang()
572+
{
573+
var yaml = """
574+
name: my-service
575+
unknown-property-1:
576+
key1: value1
577+
unknown-property-2:
578+
nested:
579+
deep: value
580+
deployment:
581+
replicas: 3
582+
resources:
583+
cpu: 100m
584+
memory: 256Mi
585+
unknown-property-3:
586+
another: nested
587+
namespace: production
588+
""";
589+
590+
var result = YamlSerializer.Deserialize(yaml, InfiniteLoopTestContext.Default.ServiceConfig);
591+
592+
Assert.NotNull(result);
593+
Assert.Equal("my-service", result.Name);
594+
Assert.Equal("production", result.Namespace);
595+
Assert.NotNull(result.Deployment);
596+
Assert.Equal(3, result.Deployment.Replicas);
597+
Assert.NotNull(result.Deployment.Resources);
598+
Assert.Equal("100m", result.Deployment.Resources.Cpu);
599+
Assert.Equal("256Mi", result.Deployment.Resources.Memory);
600+
}
601+
602+
/// <summary>
603+
/// Test: Unknown property with deeply nested mapping should be skipped correctly.
604+
/// </summary>
605+
[Fact]
606+
public void Deserialize_DeeplyNestedUnknownProperty_DoesNotHang()
607+
{
608+
var yaml = """
609+
name: my-service
610+
very-deeply-nested-unknown:
611+
level1:
612+
level2:
613+
level3:
614+
level4:
615+
level5:
616+
key: value
617+
namespace: production
618+
""";
619+
620+
var result = YamlSerializer.Deserialize(yaml, InfiniteLoopTestContext.Default.ServiceConfig);
621+
622+
Assert.NotNull(result);
623+
Assert.Equal("my-service", result.Name);
624+
Assert.Equal("production", result.Namespace);
625+
}
626+
627+
/// <summary>
628+
/// Test: Sibling discriminator with unknown discriminator value should skip the property
629+
/// and continue parsing remaining properties without causing infinite loop.
630+
/// </summary>
631+
[Fact]
632+
public void Deserialize_SiblingDiscriminatorWithUnknownValue_DoesNotHang()
633+
{
634+
var yaml = """
635+
name: my-service
636+
plugin-kind: Tracing
637+
plugin:
638+
endpoint: http://jaeger:14268
639+
sample-rate: 0.1
640+
description: A service with unknown plugin type
641+
""";
642+
643+
// PluginKind.Tracing exists as enum value but has no mapping to a concrete type
644+
// The generated code should skip the plugin property and continue to description
645+
var result = YamlSerializer.Deserialize(yaml, InfiniteLoopTestContext.Default.ServiceWithPlugins);
646+
647+
Assert.NotNull(result);
648+
Assert.Equal("my-service", result.Name);
649+
Assert.Equal(PluginType.Tracing, result.PluginKind);
650+
Assert.Null(result.Plugin); // Unknown discriminator, plugin not deserialized
651+
Assert.Equal("A service with unknown plugin type", result.Description); // Should still be parsed
652+
}
653+
654+
/// <summary>
655+
/// Test: Sibling discriminator with known value works correctly.
656+
/// </summary>
657+
[Fact]
658+
public void Deserialize_SiblingDiscriminatorWithKnownValue_WorksCorrectly()
659+
{
660+
var yaml = """
661+
name: metrics-service
662+
plugin-kind: Metrics
663+
plugin:
664+
port: 9090
665+
description: A service with metrics plugin
666+
""";
667+
668+
var result = YamlSerializer.Deserialize(yaml, InfiniteLoopTestContext.Default.ServiceWithPlugins);
669+
670+
Assert.NotNull(result);
671+
Assert.Equal("metrics-service", result.Name);
672+
Assert.Equal(PluginType.Metrics, result.PluginKind);
673+
Assert.NotNull(result.Plugin);
674+
Assert.IsType<MetricsPlugin>(result.Plugin);
675+
Assert.Equal(9090, ((MetricsPlugin)result.Plugin).Port);
676+
Assert.Equal("A service with metrics plugin", result.Description);
677+
}
678+
679+
/// <summary>
680+
/// Test: List of items with sibling discriminator where some have unknown values.
681+
/// </summary>
682+
[Fact]
683+
public void Deserialize_ListWithSiblingDiscriminatorSomeUnknown_DoesNotHang()
684+
{
685+
var yaml = """
686+
- name: service-1
687+
plugin-kind: Metrics
688+
plugin:
689+
port: 9090
690+
description: Has metrics
691+
- name: service-2
692+
plugin-kind: Tracing
693+
plugin:
694+
endpoint: http://jaeger
695+
description: Has unknown tracing
696+
- name: service-3
697+
plugin-kind: Logging
698+
plugin:
699+
level: debug
700+
description: Has logging
701+
""";
702+
703+
var result = YamlSerializer.Deserialize(yaml, InfiniteLoopTestContext.Default.ListServiceWithPlugins);
704+
705+
Assert.NotNull(result);
706+
Assert.Equal(3, result.Count);
707+
708+
// First: known discriminator
709+
Assert.Equal("service-1", result[0].Name);
710+
Assert.IsType<MetricsPlugin>(result[0].Plugin);
711+
Assert.Equal("Has metrics", result[0].Description);
712+
713+
// Second: unknown discriminator (Tracing has no mapping)
714+
Assert.Equal("service-2", result[1].Name);
715+
Assert.Null(result[1].Plugin);
716+
Assert.Equal("Has unknown tracing", result[1].Description);
717+
718+
// Third: known discriminator
719+
Assert.Equal("service-3", result[2].Name);
720+
Assert.IsType<LoggingPlugin>(result[2].Plugin);
721+
Assert.Equal("Has logging", result[2].Description);
722+
}
723+
724+
#endregion
476725
}
477726

478727
/// <summary>
@@ -488,6 +737,14 @@ public void DeserializeArrayWithAllNullElements_DoesNotHang()
488737
[YamlSerializable(typeof(InfiniteLoopRegressionTests.ContainerWithObjectDictionary))]
489738
[YamlSerializable(typeof(List<InfiniteLoopRegressionTests.ObjectItem>))]
490739
[YamlSerializable(typeof(Dictionary<string, InfiniteLoopRegressionTests.ObjectItem>))]
740+
[YamlSerializable(typeof(InfiniteLoopRegressionTests.ServiceConfig))]
741+
[YamlSerializable(typeof(InfiniteLoopRegressionTests.DeploymentConfig))]
742+
[YamlSerializable(typeof(InfiniteLoopRegressionTests.ResourceConfig))]
743+
[YamlSerializable(typeof(InfiniteLoopRegressionTests.ServiceWithPlugins))]
744+
[YamlSerializable(typeof(InfiniteLoopRegressionTests.PluginBase))]
745+
[YamlSerializable(typeof(InfiniteLoopRegressionTests.MetricsPlugin))]
746+
[YamlSerializable(typeof(InfiniteLoopRegressionTests.LoggingPlugin))]
747+
[YamlSerializable(typeof(List<InfiniteLoopRegressionTests.ServiceWithPlugins>))]
491748
public partial class InfiniteLoopTestContext : YamlSerializerContext
492749
{
493750
}

0 commit comments

Comments
 (0)