From 10b0ae7a5d8602986fdce3f8547efa13f6db0e49 Mon Sep 17 00:00:00 2001 From: Jens Geyer Date: Fri, 29 May 2026 11:42:17 +0200 Subject: [PATCH] THRIFT-2462: Add netstd recursion-depth round-trip regression test Client: netstd Recursion depth is already enforced on the generated read/write path: the netstd code generator emits IncrementRecursionDepth/DecrementRecursionDepth around the body of every generated WriteAsync/ReadAsync. This adds a round-trip regression test (Binary/Compact/JSON) over nested structs from Recursive.thrift to confirm the limit keeps being enforced, and wires Recursive.thrift into the net10 compile-test project so the generated types are available. Co-Authored-By: Claude Opus 4.8 --- .../Thrift.Compile.net10.csproj | 1 + .../Protocols/TProtocolRecursionDepthTests.cs | 182 ++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 lib/netstd/Tests/Thrift.Tests/Protocols/TProtocolRecursionDepthTests.cs diff --git a/lib/netstd/Tests/Thrift.Compile.Tests/Thrift.Compile.net10/Thrift.Compile.net10.csproj b/lib/netstd/Tests/Thrift.Compile.Tests/Thrift.Compile.net10/Thrift.Compile.net10.csproj index a9cea67ce46..1086d1a019e 100644 --- a/lib/netstd/Tests/Thrift.Compile.Tests/Thrift.Compile.net10/Thrift.Compile.net10.csproj +++ b/lib/netstd/Tests/Thrift.Compile.Tests/Thrift.Compile.net10/Thrift.Compile.net10.csproj @@ -78,6 +78,7 @@ + diff --git a/lib/netstd/Tests/Thrift.Tests/Protocols/TProtocolRecursionDepthTests.cs b/lib/netstd/Tests/Thrift.Tests/Protocols/TProtocolRecursionDepthTests.cs new file mode 100644 index 00000000000..218696939c8 --- /dev/null +++ b/lib/netstd/Tests/Thrift.Tests/Protocols/TProtocolRecursionDepthTests.cs @@ -0,0 +1,182 @@ +// Licensed to the Apache Software Foundation(ASF) under one +// or more contributor license agreements.See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership.The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Thrift; +using Thrift.Protocol; +using Thrift.Transport; +using Thrift.Transport.Client; + +namespace Thrift.Tests.Protocols +{ + // Exercises the recursion-depth limit through the *generated* struct read/write + // path (TBase.WriteAsync / TBase.ReadAsync), i.e. the real code path a malicious + // payload would hit -- not the protocol's Write/ReadStructBegin methods in isolation. + // + // The recursive IDL types (CoRec / CoRec2 / RecTree) come from test/Recursive.thrift, + // generated into the Thrift.Compile.net10 test assembly. CoRec <-> CoRec2 form a + // mutually recursive chain; RecTree is a wide tree of nested structs. + [TestClass] + public class TProtocolRecursionDepthTests + { + private const int TestRecursionLimit = 5; + + public enum ProtocolKind { Binary, Compact, Json } + + private static TProtocol MakeProtocol(ProtocolKind kind, TTransport transport) => kind switch + { + ProtocolKind.Binary => new TBinaryProtocol(transport), + ProtocolKind.Compact => new TCompactProtocol(transport), + ProtocolKind.Json => new TJsonProtocol(transport), + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + + // Build a CoRec/CoRec2 chain that is exactly 'depth' structs deep. + private static CoRec? MakeNestedRecs(int depth) + { + return (depth > 0) + ? new CoRec { Other = MakeNestedCoRec2(depth - 1) } + : null; + } + + private static CoRec2? MakeNestedCoRec2(int depth) + { + return (depth > 0) + ? new CoRec2 { Other = MakeNestedRecs(depth - 1) } + : null; + } + + // Serialize 'data' into a fresh buffer using a protocol configured with the given + // recursion limit, and return the rewound buffer ready for reading. + private static async Task WriteToBuffer(TBase data, ProtocolKind kind, int recursionLimit) + { + var stream = new MemoryStream(); + var config = new TConfiguration { RecursionLimit = recursionLimit }; + var trans = new TStreamTransport(null, stream, config); + var proto = MakeProtocol(kind, trans); + await data.WriteAsync(proto, default); + await trans.FlushAsync(default); + stream.Position = 0; + return stream; + } + + private static async Task ReadFromBuffer(TBase into, MemoryStream buffer, ProtocolKind kind, int recursionLimit) + { + buffer.Position = 0; + var config = new TConfiguration { RecursionLimit = recursionLimit }; + var trans = new TStreamTransport(buffer, null, config); + var proto = MakeProtocol(kind, trans); + await into.ReadAsync(proto, default); + } + + private static async Task AssertDepthLimitAsync(Func action) + { + try + { + await action(); + Assert.Fail("Expected TProtocolException(DEPTH_LIMIT) was not thrown"); + } + catch (TProtocolException ex) + { + Assert.AreEqual(TProtocolException.DEPTH_LIMIT, ex.GetExceptionType()); + } + } + + // A chain one level below the limit must round-trip cleanly. + [TestMethod] + [DataRow(ProtocolKind.Binary)] + [DataRow(ProtocolKind.Compact)] + [DataRow(ProtocolKind.Json)] + public async Task RoundTrip_BelowLimit_Succeeds(ProtocolKind kind) + { + var data = MakeNestedRecs(TestRecursionLimit - 1)!; + using var buffer = await WriteToBuffer(data, kind, TestRecursionLimit); + await ReadFromBuffer(new CoRec(), buffer, kind, TestRecursionLimit); + } + + // A chain exactly at the limit must still round-trip (off-by-one guard). + [TestMethod] + [DataRow(ProtocolKind.Binary)] + [DataRow(ProtocolKind.Compact)] + [DataRow(ProtocolKind.Json)] + public async Task RoundTrip_AtLimit_Succeeds(ProtocolKind kind) + { + var data = MakeNestedRecs(TestRecursionLimit)!; + using var buffer = await WriteToBuffer(data, kind, TestRecursionLimit); + await ReadFromBuffer(new CoRec(), buffer, kind, TestRecursionLimit); + } + + // Writing a chain one level over the limit must be rejected. + [TestMethod] + [DataRow(ProtocolKind.Binary)] + [DataRow(ProtocolKind.Compact)] + [DataRow(ProtocolKind.Json)] + public async Task Write_AboveLimit_Throws(ProtocolKind kind) + { + var data = MakeNestedRecs(TestRecursionLimit + 1)!; + await AssertDepthLimitAsync(() => WriteToBuffer(data, kind, TestRecursionLimit)); + } + + // Reading a too-deep payload must be rejected. The payload is produced with a + // higher limit so that valid bytes exist on the wire, then read back with the + // real limit -- mimicking a hostile message from the network. + [TestMethod] + [DataRow(ProtocolKind.Binary)] + [DataRow(ProtocolKind.Compact)] + [DataRow(ProtocolKind.Json)] + public async Task Read_AboveLimit_Throws(ProtocolKind kind) + { + var data = MakeNestedRecs(TestRecursionLimit + 1)!; + using var buffer = await WriteToBuffer(data, kind, TestRecursionLimit + 1); + await AssertDepthLimitAsync(() => ReadFromBuffer(new CoRec(), buffer, kind, TestRecursionLimit)); + } + + // Decrement regression guard: a *wide* (shallow) tree whose total number of + // struct-begins far exceeds the limit must still round-trip. This only holds + // if DecrementRecursionDepth correctly unwinds each sibling back to depth 1. + [TestMethod] + [DataRow(ProtocolKind.Binary)] + [DataRow(ProtocolKind.Compact)] + [DataRow(ProtocolKind.Json)] + public async Task RoundTrip_WideStructure_Succeeds(ProtocolKind kind) + { + var tree = new RecTree { Item = 0, Children = new List() }; + for (var i = 0; i < (TestRecursionLimit * 3); i++) + tree.Children.Add(new RecTree { Item = (short)i, Children = new List() }); + + using var buffer = await WriteToBuffer(tree, kind, TestRecursionLimit); + await ReadFromBuffer(new RecTree(), buffer, kind, TestRecursionLimit); + } + + // A cyclic object graph would recurse forever without the limit; it must instead + // fail with DEPTH_LIMIT. + [TestMethod] + [DataRow(ProtocolKind.Binary)] + [DataRow(ProtocolKind.Compact)] + [DataRow(ProtocolKind.Json)] + public async Task CyclicGraph_Throws(ProtocolKind kind) + { + var data = MakeNestedRecs(2)!; // CoRec -> CoRec2 -> null + data.Other!.Other = data; // close the loop: CoRec2.Other -> CoRec + await AssertDepthLimitAsync(() => WriteToBuffer(data, kind, TestRecursionLimit)); + } + } +}