From 14892bf6dadad3a6ed3d43cff99ca6813abdefd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:32:11 +0000 Subject: [PATCH 1/2] Refuse to write Newtonsoft JSON deeper than it can read back [patch] Newtonsoft enforces MaxDepth only when reading, so TrySerialize wrote graphs deeper than 64 levels that Deserialize then returned null for. Write through a JsonTextWriter that throws past the same explicit MaxDepth, which TrySerialize reports as false. Fixes #155 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SETCEsuMvZFcF6qXRguNds --- .../NewtonsoftJsonSerializationProvider.cs | 35 ++++++++++++++++- ...ewtonsoftJsonSerializationProviderTests.cs | 39 +++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/Essentials.SerializationProviders.NewtonsoftJson/NewtonsoftJsonSerializationProvider.cs b/Essentials.SerializationProviders.NewtonsoftJson/NewtonsoftJsonSerializationProvider.cs index 581d90b..3fa67c1 100644 --- a/Essentials.SerializationProviders.NewtonsoftJson/NewtonsoftJsonSerializationProvider.cs +++ b/Essentials.SerializationProviders.NewtonsoftJson/NewtonsoftJsonSerializationProvider.cs @@ -18,7 +18,11 @@ public class NewtonsoftJsonSerializationProvider : ISerializationProvider /// public string FileExtension => ".json"; - private readonly JsonSerializerSettings settings = new(); + // Newtonsoft enforces MaxDepth only when reading, so writing is held to the same limit + // by DepthLimitedJsonTextWriter. Without it, TrySerialize accepts graphs Deserialize refuses. + private const int MaxDepth = 64; + + private readonly JsonSerializerSettings settings = new() { MaxDepth = MaxDepth }; /// /// Tries to serialize the specified object into the writer. @@ -36,7 +40,7 @@ public bool TrySerialize(object obj, TextWriter writer) try { // The caller owns the writer, so disposing the JSON writer must not close it. - using JsonTextWriter jsonWriter = new(writer) { CloseOutput = false }; + using DepthLimitedJsonTextWriter jsonWriter = new(writer, MaxDepth) { CloseOutput = false }; JsonSerializer serializer = JsonSerializer.Create(settings); serializer.Serialize(jsonWriter, obj); jsonWriter.Flush(); @@ -91,4 +95,31 @@ public bool TrySerialize(object obj, TextWriter writer) return default; } } + + /// + /// A that refuses to nest objects or arrays deeper than the + /// reader will accept, so everything it writes can be read back. + /// + private sealed class DepthLimitedJsonTextWriter(TextWriter textWriter, int maxDepth) : JsonTextWriter(textWriter) + { + public override void WriteStartObject() + { + base.WriteStartObject(); + EnsureDepth(); + } + + public override void WriteStartArray() + { + base.WriteStartArray(); + EnsureDepth(); + } + + private void EnsureDepth() + { + if (Top > maxDepth) + { + throw new JsonWriterException($"The object graph is deeper than the maximum depth of {maxDepth}."); + } + } + } } diff --git a/Essentials.Tests/NewtonsoftJsonSerializationProviderTests.cs b/Essentials.Tests/NewtonsoftJsonSerializationProviderTests.cs index ed39c5e..f09fc0f 100644 --- a/Essentials.Tests/NewtonsoftJsonSerializationProviderTests.cs +++ b/Essentials.Tests/NewtonsoftJsonSerializationProviderTests.cs @@ -2,7 +2,9 @@ namespace ktsu.Essentials.Tests; +using System.Globalization; using System.IO; +using ktsu.Essentials; using ktsu.Essentials.SerializationProviders.NewtonsoftJson; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -28,6 +30,43 @@ public void TrySerialize_WriterThrowingIOException_ReturnsFalse() Assert.IsFalse(provider.TrySerialize(new { A = 1 }, writer)); } + [TestMethod] + [DataRow(64)] + [DataRow(65)] + [DataRow(200)] + public void TrySerialize_DeepChain_SucceedsOnlyWhenItDeserializes(int depth) + { + NewtonsoftJsonSerializationProvider provider = new(); + Node root = new() { Name = "0" }; + Node tail = root; + for (int i = 1; i < depth; i++) + { + tail.Next = new Node { Name = i.ToString(CultureInfo.InvariantCulture) }; + tail = tail.Next; + } + + using StringWriter writer = new(); + bool serialized = provider.TrySerialize(root, writer); + Node? roundTripped = ((ISerializationProvider)provider).Deserialize(writer.ToString()); + + if (depth <= 64) + { + Assert.IsTrue(serialized); + Assert.IsNotNull(roundTripped); + } + else + { + Assert.IsFalse(serialized); + } + } + + private sealed class Node + { + public string Name { get; set; } = string.Empty; + + public Node? Next { get; set; } + } + private sealed class FailingWriter : StringWriter { public override void Write(char value) => throw new IOException("disk full"); From 9229c90aab3b03edc73f12bf1f34ad956bc30bf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 14:14:50 +0000 Subject: [PATCH 2/2] Cover the array path of the depth-limited Newtonsoft writer Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SETCEsuMvZFcF6qXRguNds --- ...ewtonsoftJsonSerializationProviderTests.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Essentials.Tests/NewtonsoftJsonSerializationProviderTests.cs b/Essentials.Tests/NewtonsoftJsonSerializationProviderTests.cs index f09fc0f..0b16e40 100644 --- a/Essentials.Tests/NewtonsoftJsonSerializationProviderTests.cs +++ b/Essentials.Tests/NewtonsoftJsonSerializationProviderTests.cs @@ -60,6 +60,33 @@ public void TrySerialize_DeepChain_SucceedsOnlyWhenItDeserializes(int depth) } } + [TestMethod] + [DataRow(64)] + [DataRow(65)] + public void TrySerialize_DeeplyNestedArrays_SucceedsOnlyWhenItDeserializes(int depth) + { + NewtonsoftJsonSerializationProvider provider = new(); + object[] root = []; + for (int i = 1; i < depth; i++) + { + root = [root]; + } + + using StringWriter writer = new(); + bool serialized = provider.TrySerialize(root, writer); + object? roundTripped = ((ISerializationProvider)provider).Deserialize(writer.ToString()); + + if (depth <= 64) + { + Assert.IsTrue(serialized); + Assert.IsNotNull(roundTripped); + } + else + { + Assert.IsFalse(serialized); + } + } + private sealed class Node { public string Name { get; set; } = string.Empty;