diff --git a/AustinHarris.JsonRpcTestN/AspNetCoreTests.cs b/AustinHarris.JsonRpcTestN/AspNetCoreTests.cs index 3df78b3..0c32acb 100644 --- a/AustinHarris.JsonRpcTestN/AspNetCoreTests.cs +++ b/AustinHarris.JsonRpcTestN/AspNetCoreTests.cs @@ -72,6 +72,7 @@ public async Task StartHost() _app = builder.Build(); _app.MapJsonRpc("/rpc"); + _app.MapJsonRpc("/raised-limit", new JsonRpcOptions { MaxRequestBytes = 6 * 1024 * 1024 }); await _app.StartAsync(); var addresses = _app.Services.GetRequiredService().Features.Get().Addresses; @@ -157,11 +158,28 @@ public async Task Http_DiService_IsBoundAndSeesHttpContext() [Test] public async Task Http_LargeBody_Is413() { - var big = "{\"jsonrpc\":\"2.0\",\"method\":\"internal.echo\",\"params\":[\"" + new string('x', 5 * 1024 * 1024) + "\"],\"id\":1}"; + var big = SizedDocument(4 * 1024 * 1024 + 1); var response = await PostAsync(big); Assert.AreEqual(HttpStatusCode.RequestEntityTooLarge, response.StatusCode); } + [Test] + public async Task Http_RaisedTransportLimit_UsesCoreDocumentLimit() + { + var response = await _http.PostAsync("/raised-limit", + new StringContent(SizedDocument(4 * 1024 * 1024 + 1), Encoding.UTF8, "application/json")); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32600,\"message\":\"Invalid Request\",\"data\":{\"limit\":\"maxDocumentBytes\",\"maximum\":4194304}},\"id\":null}", + await response.Content.ReadAsStringAsync()); + } + + private static string SizedDocument(int bytes) + { + const string prefix = "{\"method\":\"IntToInt\",\"params\":[\""; + const string suffix = "\"],\"id\":1}"; + return prefix + new string('x', bytes - prefix.Length - suffix.Length) + suffix; + } + [Test] public async Task Tcp_TwoDocumentsInOneWrite_AreAnsweredInOrder() { diff --git a/AustinHarris.JsonRpcTestN/AsyncInvocationTests.cs b/AustinHarris.JsonRpcTestN/AsyncInvocationTests.cs index 091b824..1fa7ab4 100644 --- a/AustinHarris.JsonRpcTestN/AsyncInvocationTests.cs +++ b/AustinHarris.JsonRpcTestN/AsyncInvocationTests.cs @@ -140,9 +140,9 @@ public async Task CompatibilityRegistrationSurfaces_SupportAsyncMethods(int surf var method = new Func>(() => Task.FromResult(7)); var handler = Handler.GetSessionHandler(_session); if (surface == 0) handler.MetaData.Services["run"] = new SMDService("POST", "JSON-RPC-2.0", types, new Dictionary(), method); -#pragma warning disable CS0618 +#pragma warning disable CS0618, JSONRPC0002 else if (surface == 1) handler.RegisterFuction("run", types, null, method); -#pragma warning restore CS0618 +#pragma warning restore CS0618, JSONRPC0002 else _ = new AutoAsyncService(_session); Assert.AreEqual(7, (int)JObject.Parse(await Run(Request("run")))["result"]); Assert.AreEqual(typeof(int), handler.MetaData.Services["run"].Method.ResultType); @@ -180,9 +180,9 @@ public void AsyncVoid_IsRejectedOnEverySurface(int surface) 3 => () => Bind("invalid", invalid), 4 => () => new InvalidAutoService(_session), 5 => () => new SMDService("POST", "JSON-RPC-2.0", types, new Dictionary(), invalid), -#pragma warning disable CS0618 +#pragma warning disable CS0618, JSONRPC0002 _ => () => Handler.GetSessionHandler(_session).RegisterFuction("invalid", types, null, invalid) -#pragma warning restore CS0618 +#pragma warning restore CS0618, JSONRPC0002 }; StringAssert.Contains("async void", Assert.Throws(registration).Message); } diff --git a/AustinHarris.JsonRpcTestN/DelegateBindingTests.cs b/AustinHarris.JsonRpcTestN/DelegateBindingTests.cs index ae58f72..da5c723 100644 --- a/AustinHarris.JsonRpcTestN/DelegateBindingTests.cs +++ b/AustinHarris.JsonRpcTestN/DelegateBindingTests.cs @@ -128,9 +128,9 @@ public void Names_MustBeFree_AndUnbindFreesThem() Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":2,\"id\":1}", Run("{\"method\":\"m\",\"id\":1}")); // the legacy surface keeps replacing silently -#pragma warning disable CS0618 +#pragma warning disable CS0618, JSONRPC0002 Handler.GetSessionHandler(Session).RegisterFuction("m", new Dictionary { ["returns"] = typeof(int) }, null, new Func(() => 3)); -#pragma warning restore CS0618 +#pragma warning restore CS0618, JSONRPC0002 Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":3,\"id\":1}", Run("{\"method\":\"m\",\"id\":1}")); } diff --git a/AustinHarris.JsonRpcTestN/DispatchHardeningTests.cs b/AustinHarris.JsonRpcTestN/DispatchHardeningTests.cs index 439f30c..c64545e 100644 --- a/AustinHarris.JsonRpcTestN/DispatchHardeningTests.cs +++ b/AustinHarris.JsonRpcTestN/DispatchHardeningTests.cs @@ -154,9 +154,9 @@ public void BindServices() public void DestroySessions() { Handler.DestroySession(Session); -#pragma warning disable CS0618 +#pragma warning disable CS0618, JSONRPC0003 Handler.DefaultHandler.UnRegisterFunction("dh.whichSession"); -#pragma warning restore CS0618 +#pragma warning restore CS0618, JSONRPC0003 } private static string Run(string json, object context = null, JsonRpcSerializer serializer = null, string session = Session) diff --git a/AustinHarris.JsonRpcTestN/LimitsTests.cs b/AustinHarris.JsonRpcTestN/LimitsTests.cs new file mode 100644 index 0000000..c975ae4 --- /dev/null +++ b/AustinHarris.JsonRpcTestN/LimitsTests.cs @@ -0,0 +1,287 @@ +using System; +using System.Buffers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AustinHarris.JsonRpc; +using AustinHarris.JsonRpc.Serialization; +using NUnit.Framework; + +namespace AustinHarris.JsonRpcTestN +{ + [TestFixture] + [NonParallelizable] + public class LimitsTests + { + private const string Call = "{\"jsonrpc\":\"2.0\",\"method\":\"limits.hit\",\"id\":1}"; + private const string Notification = "{\"jsonrpc\":\"2.0\",\"method\":\"limits.hit\"}"; + private const string Result = "{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":1}"; + private string _session; + private Service _service; + + public sealed class Service + { + public int Calls; + [JsonRpcMethod("limits.hit")] + public int Hit() { Interlocked.Increment(ref Calls); return 7; } + } + + private sealed class Segment : ReadOnlySequenceSegment + { + internal Segment(ReadOnlyMemory memory) { Memory = memory; } + internal Segment Append(ReadOnlyMemory memory) + { + var next = new Segment(memory) { RunningIndex = RunningIndex + Memory.Length }; + Next = next; + return next; + } + + internal Segment AppendAt(long index, ReadOnlyMemory memory) + { + var next = new Segment(memory) { RunningIndex = index }; + Next = next; + return next; + } + } + + [SetUp] + public void SetUp() + { + _session = "limits-" + Guid.NewGuid().ToString("N"); + _service = new Service(); + ServiceBinder.BindService(_session, _service); + Config.SetLimits(JsonRpcLimits.Default); + } + + [TearDown] + public void TearDown() + { + Config.SetLimits(JsonRpcLimits.Default); + Handler.DestroySession(_session); + } + + private static ReadOnlySequence Split(byte[] bytes) + { + int middle = bytes.Length / 2; + var first = new Segment(bytes.AsMemory(0, middle)); + var last = first.Append(bytes.AsMemory(middle)); + return new ReadOnlySequence(first, 0, last, last.Memory.Length); + } + + private static string Text(ArrayBufferWriter output) => Encoding.UTF8.GetString(output.WrittenSpan); + + private static string LimitError(string name, long maximum) => + "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32600,\"message\":\"Invalid Request\",\"data\":{\"limit\":\"" + + name + "\",\"maximum\":" + maximum + "}},\"id\":null}"; + + [TestCase("jsmn")] + [TestCase("newtonsoft")] + [TestCase("stj")] + public async Task DocumentBytes_AllPublicEntriesRejectBeforeDispatch(string serializerName) + { + var serializer = SerializerCatalog.Create(serializerName); + byte[] bytes = Encoding.UTF8.GetBytes(Call); + long maximum = bytes.Length - 1; + string expected = LimitError("maxDocumentBytes", maximum); + Config.SetLimits(new JsonRpcLimits(maximum, 0)); + + var output = new ArrayBufferWriter(); + var single = new ReadOnlySequence(bytes); + JsonRpcProcessor.Process(_session, in single, output, serializer: serializer); + Assert.AreEqual(expected, Text(output), "sync single-segment sequence"); + output.Clear(); + var multi = Split(bytes); + JsonRpcProcessor.Process(_session, in multi, output, serializer: serializer); + Assert.AreEqual(expected, Text(output), "sync multi-segment sequence"); + output.Clear(); + JsonRpcProcessor.Process(_session, bytes.AsMemory(), output, serializer: serializer); + Assert.AreEqual(expected, Text(output), "sync memory"); + output.Clear(); + JsonRpcProcessor.Process(_session, bytes.AsSpan(), output, serializer: serializer); + Assert.AreEqual(expected, Text(output), "sync span"); + Assert.AreEqual(expected, Encoding.UTF8.GetString(JsonRpcProcessor.ProcessBytes(_session, bytes.AsSpan(), serializer: serializer))); + Assert.AreEqual(expected, JsonRpcProcessor.ProcessSync(_session, Call, null, serializer)); + Assert.AreEqual(expected, await JsonRpcProcessor.Process(_session, Call, null, serializer)); + + output.Clear(); + await JsonRpcProcessor.ProcessAsync(_session, single, output, serializer: serializer); + Assert.AreEqual(expected, Text(output), "async single-segment sequence"); + output.Clear(); + await JsonRpcProcessor.ProcessAsync(_session, multi, output, serializer: serializer); + Assert.AreEqual(expected, Text(output), "async multi-segment sequence"); + output.Clear(); + await JsonRpcProcessor.ProcessAsync(_session, bytes.AsMemory(), output, serializer: serializer); + Assert.AreEqual(expected, Text(output), "async memory"); + output.Clear(); + await JsonRpcProcessor.ProcessAsync(_session, bytes.AsSpan(), output, serializer: serializer); + Assert.AreEqual(expected, Text(output), "async span"); + Assert.AreEqual(expected, await JsonRpcProcessor.ProcessAsync(_session, Call, serializer: serializer)); + Assert.AreEqual(0, _service.Calls); + } + + [Test] + public async Task DefaultSessionAndStateWrappersInheritByteCheck() + { + Config.SetLimits(new JsonRpcLimits(1, 0)); + string expected = LimitError("maxDocumentBytes", 1); + Assert.AreEqual(expected, JsonRpcProcessor.ProcessSync(Call)); + Assert.AreEqual(expected, JsonRpcProcessor.ProcessSync(SerializerCatalog.Create("jsmn"), Call)); + Assert.AreEqual(expected, await JsonRpcProcessor.Process(Call)); + Assert.AreEqual(expected, await JsonRpcProcessor.Process(SerializerCatalog.Create("jsmn"), Call)); + Assert.AreEqual(expected, await JsonRpcProcessor.ProcessAsync(Call)); + + async Task StateResult(bool defaultSession) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var state = new JsonRpcStateAsync(ar => completion.SetResult(((JsonRpcStateAsync)ar).Result), null) { JsonRpc = Call }; + if (defaultSession) JsonRpcProcessor.Process(state); + else JsonRpcProcessor.Process(_session, state); + return await completion.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + + Assert.AreEqual(expected, await StateResult(false)); + Assert.AreEqual(expected, await StateResult(true)); + Assert.AreEqual(0, _service.Calls); + } + + [Test] + public async Task SequenceLengthIsCheckedBeforeIntConversionOrFlattening() + { + Config.SetLimits(_session, new JsonRpcLimits(1, 0)); + var first = new Segment(ReadOnlyMemory.Empty); + var last = first.AppendAt((long)int.MaxValue + 1, ReadOnlyMemory.Empty); + var sequence = new ReadOnlySequence(first, 0, last, 0); + var output = new ArrayBufferWriter(); + JsonRpcProcessor.Process(_session, in sequence, output); + Assert.AreEqual(LimitError("maxDocumentBytes", 1), Text(output)); + output.Clear(); + await JsonRpcProcessor.ProcessAsync(_session, sequence, output); + Assert.AreEqual(LimitError("maxDocumentBytes", 1), Text(output)); + } + + [Test] + public void AsyncPreCancellationWinsOverDocumentLimit() + { + Config.SetLimits(_session, new JsonRpcLimits(1, 0)); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var bytes = Encoding.UTF8.GetBytes(Call); + var output = new ArrayBufferWriter(); + var sequence = new ReadOnlySequence(bytes); + Assert.IsTrue(JsonRpcProcessor.ProcessAsync(_session, sequence, output, cancellationToken: cts.Token).IsCanceled); + Assert.IsTrue(JsonRpcProcessor.ProcessAsync(_session, bytes.AsMemory(), output, cancellationToken: cts.Token).IsCanceled); + Assert.IsTrue(JsonRpcProcessor.ProcessAsync(_session, bytes.AsSpan(), output, cancellationToken: cts.Token).IsCanceled); + Assert.IsTrue(JsonRpcProcessor.ProcessAsync(_session, Call, cancellationToken: cts.Token).IsCanceled); + Assert.AreEqual(0, output.WrittenCount); + } + + [TestCase("jsmn")] + [TestCase("newtonsoft")] + [TestCase("stj")] + public async Task BatchCountRejectsWholeMixedAndNotificationOnlyBatches(string serializerName) + { + var serializer = SerializerCatalog.Create(serializerName); + Config.SetLimits(_session, new JsonRpcLimits(0, 2)); + string expected = LimitError("maxBatchCount", 2); + string mixed = "[" + Call + "," + Notification + ",42]"; + string notifications = "[" + Notification + "," + Notification + "," + Notification + "]"; + foreach (string batch in new[] { mixed, notifications }) + { + Assert.AreEqual(expected, JsonRpcProcessor.ProcessSync(_session, batch, null, serializer)); + Assert.AreEqual(expected, await JsonRpcProcessor.ProcessAsync(_session, batch, serializer: serializer)); + } + Assert.AreEqual(0, _service.Calls); + } + + [TestCase("jsmn")] + [TestCase("newtonsoft")] + [TestCase("stj")] + public async Task StringLimitUsesUtf8ByteCount(string serializerName) + { + var serializer = SerializerCatalog.Create(serializerName); + string ascii = "{\"method\":\"limits.hit\",\"id\":1,\"note\":\"aa\"}"; + string multibyte = "{\"method\":\"limits.hit\",\"id\":1,\"note\":\"€€\"}"; + int maximum = Encoding.UTF8.GetByteCount(ascii); + Config.SetLimits(_session, new JsonRpcLimits(maximum, 0)); + Assert.AreEqual(Result, JsonRpcProcessor.ProcessSync(_session, ascii, null, serializer)); + Assert.AreEqual(LimitError("maxDocumentBytes", maximum), JsonRpcProcessor.ProcessSync(_session, multibyte, null, serializer)); + Assert.AreEqual(LimitError("maxDocumentBytes", maximum), await JsonRpcProcessor.ProcessAsync(_session, multibyte, serializer: serializer)); + Assert.AreEqual(1, _service.Calls); + } + + [Test] + public void ConfigurationRejectsNegativesAndNullGlobal_AndSessionCanInherit() + { + Assert.AreEqual(4 * 1024 * 1024, JsonRpcLimits.Default.MaxDocumentBytes); + Assert.AreEqual(1024, JsonRpcLimits.Default.MaxBatchCount); + Assert.AreEqual(0, JsonRpcLimits.Unlimited.MaxDocumentBytes); + Assert.AreEqual(0, JsonRpcLimits.Unlimited.MaxBatchCount); + Assert.AreEqual("maxDocumentBytes", Assert.Throws(() => new JsonRpcLimits(-1, 1)).ParamName); + Assert.AreEqual("maxBatchCount", Assert.Throws(() => new JsonRpcLimits(1, -1)).ParamName); + Assert.Throws(() => Config.SetLimits(null)); + + var global = new JsonRpcLimits(1, 0); + Config.SetLimits(global); + Config.SetLimits(_session, null); + Assert.AreSame(global, Config.Limits); + Assert.AreEqual(LimitError("maxDocumentBytes", 1), JsonRpcProcessor.ProcessSync(_session, Call, null)); + Config.SetLimits(_session, new JsonRpcLimits(0, 0)); + Assert.AreEqual(Result, JsonRpcProcessor.ProcessSync(_session, Call, null)); + Assert.AreEqual(1, _service.Calls); + Config.SetLimits(_session, null); + Assert.AreEqual(LimitError("maxDocumentBytes", 1), JsonRpcProcessor.ProcessSync(_session, Call, null)); + + string created = "limits-created-" + Guid.NewGuid().ToString("N"); + try + { + Config.SetLimits(created, null); + Assert.IsTrue(Handler.TryGetSessionHandler(created, out var handler)); + Assert.IsNull(handler.Limits); + } + finally { Handler.DestroySession(created); } + } + + [Test] + public void ZeroDisablesEachField_AndUnlimitedAcceptsFiveMiB() + { + Config.SetLimits(_session, new JsonRpcLimits(0, 1)); + string large = "{\"method\":\"limits.hit\",\"id\":1,\"note\":\"" + new string('x', 5 * 1024 * 1024) + "\"}"; + Assert.AreEqual(Result, JsonRpcProcessor.ProcessSync(_session, large, null)); + Config.SetLimits(_session, new JsonRpcLimits(0, 0)); + Assert.AreEqual("[" + Result + "," + Result + "]", JsonRpcProcessor.ProcessSync(_session, "[" + Call + "," + Call + "]", null)); + Config.SetLimits(_session, JsonRpcLimits.Unlimited); + Assert.AreEqual(Result, JsonRpcProcessor.ProcessSync(_session, large, null)); + Assert.AreEqual(4, _service.Calls); + } + + [Test] + public async Task LimitErrorsReachParseHandler_ButNotPreProcessHandler() + { + int parsed = 0, pre = 0; + Config.SetLimits(_session, new JsonRpcLimits(1, 1)); + Config.SetParseErrorHandler(_session, (raw, error) => + { + Assert.IsNotNull(raw); + Assert.AreEqual(-32600, error.code); + Assert.IsInstanceOf(error.data); + parsed++; + return error; + }); + Config.SetPreProcessHandler(_session, (request, context) => { pre++; return null; }); + try + { + Assert.AreEqual(LimitError("maxDocumentBytes", 1), JsonRpcProcessor.ProcessSync(_session, Call, null)); + Config.SetLimits(_session, new JsonRpcLimits(0, 1)); + Assert.AreEqual(LimitError("maxBatchCount", 1), await JsonRpcProcessor.ProcessAsync(_session, "[" + Call + "," + Call + "]")); + Assert.AreEqual(2, parsed); + Assert.AreEqual(0, pre); + Assert.AreEqual(0, _service.Calls); + } + finally + { + Config.SetParseErrorHandler(_session, null); + Config.SetPreProcessHandler(_session, null); + } + } + } +} diff --git a/AustinHarris.JsonRpcTestN/NewtonsoftTests.cs b/AustinHarris.JsonRpcTestN/NewtonsoftTests.cs index 8075e64..e42dff8 100644 --- a/AustinHarris.JsonRpcTestN/NewtonsoftTests.cs +++ b/AustinHarris.JsonRpcTestN/NewtonsoftTests.cs @@ -93,9 +93,9 @@ public void Settings_PerSession_OverridesGlobal() try { var h = Handler.GetSessionHandler(sessionId); -#pragma warning disable CS0618 +#pragma warning disable CS0618, JSONRPC0002 h.RegisterFuction("echo", new System.Collections.Generic.Dictionary { { "s", typeof(string) }, { "returns", typeof(string) } }, null, new Func(s => s)); -#pragma warning restore CS0618 +#pragma warning restore CS0618, JSONRPC0002 h.Serializer = new NewtonsoftJsonRpcSerializer(new JsonSerializerSettings { Converters = { new ShoutingStringConverter() } }); // four arguments on purpose: ProcessSync(sessionId, json, null) binds to the (jsonRpc, context, serializer) overload var result = JsonRpcProcessor.ProcessSync(sessionId, "{\"method\":\"echo\",\"params\":[\"abc\"],\"id\":1}", null, null); diff --git a/AustinHarris.JsonRpcTestN/ObsoletionTests.cs b/AustinHarris.JsonRpcTestN/ObsoletionTests.cs new file mode 100644 index 0000000..56fdea1 --- /dev/null +++ b/AustinHarris.JsonRpcTestN/ObsoletionTests.cs @@ -0,0 +1,75 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using AustinHarris.JsonRpc; +using NUnit.Framework; + +namespace AustinHarris.JsonRpcTestN +{ + /// Verifies the stable diagnostics on obsolete public APIs. + [TestFixture] + public class ObsoletionTests + { + private const string UrlFormat = "https://astn.github.io/JSON-RPC.NET/obsoletions.html#{0}"; + + /// Checks the session pre-process alias diagnostic. + [Test] + public void SetBeforeProcessHandler_HasDiagnosticAndMigrationMessage() + { + AssertObsolete(typeof(Config), "SetBeforeProcessHandler", "JSONRPC0001", + "Use SetPreProcessHandler(sessionId, handler)."); + } + + /// Checks the legacy registration diagnostic. + [Test] + public void RegisterFuction_HasDiagnosticAndMigrationMessage() + { + AssertObsolete(typeof(Handler), "RegisterFuction", "JSONRPC0002", + "Use ServiceBinder.BindMethod; unlike RegisterFuction it throws when the name is already registered instead of replacing it."); + } + + /// Checks the legacy unregistration diagnostic. + [Test] + public void UnRegisterFunction_HasDiagnosticAndMigrationMessage() + { + AssertObsolete(typeof(Handler), "UnRegisterFunction", "JSONRPC0003", + "Use ServiceBinder.UnbindMethod."); + } + + /// Checks that core obsoletion IDs are reserved, unique and discoverable. + [Test] + public void DiagnosticIds_AreUniqueAndInTheObsoletionRange() + { + var flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + var ids = typeof(Config).Assembly.GetTypes() + .SelectMany(type => new MemberInfo[] { type }.Concat(type.GetMembers(flags))) + .Select(member => member.GetCustomAttribute()) + .Where(attribute => attribute != null && attribute.DiagnosticId != null) + .Select(attribute => attribute.DiagnosticId) + .ToArray(); + + Assert.GreaterOrEqual(ids.Length, 3, "The scan must find the three current obsoletions."); + CollectionAssert.Contains(ids, "JSONRPC0001"); + CollectionAssert.Contains(ids, "JSONRPC0002"); + CollectionAssert.Contains(ids, "JSONRPC0003"); + Assert.AreEqual(ids.Length, ids.Distinct(StringComparer.Ordinal).Count(), "Diagnostic IDs must be unique."); + foreach (string id in ids) + Assert.IsTrue(Regex.IsMatch(id, @"^JSONRPC0\d{3}$"), $"Unexpected diagnostic ID: {id}"); + } + + private static void AssertObsolete(Type declaringType, string memberName, string diagnosticId, string message) + { + var method = declaringType.GetMethod(memberName); + Assert.NotNull(method); + var attribute = method.GetCustomAttribute(); + Assert.NotNull(attribute); + Assert.IsFalse(attribute.IsError); +#if NET5_0_OR_GREATER + Assert.AreEqual(diagnosticId, attribute.DiagnosticId); + Assert.AreEqual(UrlFormat, attribute.UrlFormat); +#endif + Assert.AreEqual(message, attribute.Message); + } + } +} diff --git a/AustinHarris.JsonRpcTestN/ReservedNameTests.cs b/AustinHarris.JsonRpcTestN/ReservedNameTests.cs new file mode 100644 index 0000000..9ccae67 --- /dev/null +++ b/AustinHarris.JsonRpcTestN/ReservedNameTests.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Text; +using AustinHarris.JsonRpc; +using NUnit.Framework; + +namespace AustinHarris.JsonRpcTestN +{ + /// + /// Reserved method names (rpc.-prefixed and $/cancelRequest) are refused by + /// itself, so every registration path refuses them. + /// + [TestFixture] + public sealed class ReservedNameTests + { + private const string Session = "reserved-names"; + private static readonly string[] Reserved = { "rpc.x", "$/cancelRequest" }; + private static readonly string[] Allowed = { "$/progress", "rpcx", "Rpc.x", "x.rpc.y", "$/cancelrequest" }; + private static SMDServiceCollection Services => Handler.GetSessionHandler(Session).MetaData.Services; + + [TearDown] + public void Clean() => Handler.DestroySession(Session); + + private static SMDService NewService(int result) + { + return new SMDService("POST", "JSON-RPC-2.0", new Dictionary { ["returns"] = typeof(int) }, new Dictionary(), new Func(() => result)); + } + + private static SMDService Find(string name) => Services.Find(Encoding.UTF8.GetBytes(name)); + + private static void AssertReserved(string name, TestDelegate register) + { + var ex = Assert.Throws(register, name); + StringAssert.StartsWith("'" + name + "' is a reserved JSON-RPC method name.", ex.Message); + Assert.IsFalse(Services.ContainsKey(name), name); + Assert.IsNull(Find(name), name); + } + + private static void AssertRegistered(string name) + { + Assert.IsTrue(Services.ContainsKey(name), name); + Assert.IsNotNull(Find(name), name); + } + + private interface IPair + { + int First(); + int Second(); + } + + private sealed class Pair : IPair + { + public int First() => 1; + public int Second() => 2; + } + + private sealed class ReservedAlias + { + [JsonRpcMethod("rpc.x")] + public int M() => 1; + } + + private sealed class ReservedCancelAlias + { + [JsonRpcMethod("$/cancelRequest")] + public int M() => 1; + } + + private sealed class AllowedAliases + { + [JsonRpcMethod("$/progress")] + [JsonRpcMethod("rpcx")] + [JsonRpcMethod("Rpc.x")] + [JsonRpcMethod("x.rpc.y")] + [JsonRpcMethod("$/cancelrequest")] + public int M() => 1; + } + + private sealed class NullKeyEntries : IReadOnlyDictionary + { + public int Count => 1; + public IEnumerable Keys => new string[] { null }; + public IEnumerable Values => new SMDService[] { null }; + public SMDService this[string key] => throw new NotImplementedException(); + public bool ContainsKey(string key) => false; + public bool TryGetValue(string key, out SMDService value) { value = null; return false; } + public IEnumerator> GetEnumerator() + { + yield return new KeyValuePair(null, null); + } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + [Test] + public void BindMethod_RefusesReserved_AcceptsTheRest() + { + foreach (var name in Reserved) + AssertReserved(name, () => ServiceBinder.BindMethod(Session, name, () => 1)); + foreach (var name in Allowed) + { + ServiceBinder.BindMethod(Session, name, () => 7); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":1}", + JsonRpcProcessor.ProcessSync(Session, "{\"method\":\"" + name + "\",\"id\":1}", null), name); + } + } + + [Test] + public void BindInterface_RefusesReserved_AcceptsTheRest() + { + foreach (var name in Reserved) + { + AssertReserved(name, () => ServiceBinder.BindInterface(Session, new Pair(), + new RpcInterfaceBindingOptions { NameRule = m => m.Leaf == "First" ? name : "second" })); + Assert.AreEqual(0, Services.Count); + } + foreach (var name in Allowed) + { + ServiceBinder.BindInterface(Session, new Pair(), + new RpcInterfaceBindingOptions { NameRule = m => m.Leaf == "First" ? name : "second." + name }); + AssertRegistered(name); + AssertRegistered("second." + name); + } + } + + [Test] + public void AttributeBinder_RefusesReservedAliases_AcceptsTheRest() + { + AssertReserved("rpc.x", () => ServiceBinder.BindService(Session, new ReservedAlias())); + AssertReserved("$/cancelRequest", () => ServiceBinder.BindService(Session, new ReservedCancelAlias())); + Assert.AreEqual(0, Services.Count); + ServiceBinder.BindService(Session, new AllowedAliases()); + foreach (var name in Allowed) AssertRegistered(name); + } + + [Test] + public void RegisterFuction_RefusesReserved_AcceptsTheRest() + { + var handler = Handler.GetSessionHandler(Session); +#pragma warning disable CS0618, JSONRPC0002 + foreach (var name in Reserved) + AssertReserved(name, () => handler.RegisterFuction(name, new Dictionary { ["returns"] = typeof(int) }, null, new Func(() => 1))); + foreach (var name in Allowed) + { + handler.RegisterFuction(name, new Dictionary { ["returns"] = typeof(int) }, null, new Func(() => 1)); + AssertRegistered(name); + } +#pragma warning restore CS0618, JSONRPC0002 + } + + [Test] + public void DirectCollectionAdds_RefuseReserved_AcceptTheRest() + { + var services = Services; + foreach (var name in Reserved) + { + AssertReserved(name, () => services.Add(name, NewService(1))); + AssertReserved(name, () => services.Add(new KeyValuePair(name, NewService(1)))); + AssertReserved(name, () => services[name] = NewService(1)); + Assert.AreEqual("key", Assert.Throws(() => services.Add(name, NewService(1))).ParamName); + Assert.AreEqual("key", Assert.Throws(() => services[name] = NewService(1)).ParamName); + } + Assert.AreEqual(0, services.Count); + + foreach (var name in Allowed) + { + services.Add(name, NewService(1)); + AssertRegistered(name); + Assert.IsTrue(services.Remove(name)); + services.Add(new KeyValuePair(name, NewService(2))); + AssertRegistered(name); + var replacement = NewService(3); + services[name] = replacement; + Assert.AreSame(replacement, Find(name)); + } + + Assert.Throws(() => services.Add(null, NewService(1))); + Assert.Throws(() => services[null] = NewService(1)); + } + + [Test] + public void Names_AreComparedAsGiven() + { + // no trimming and no case folding: these are ordinary names + foreach (var name in new[] { " rpc.x", "RPC.x", "$/CancelRequest", "$/cancelRequest ", "rpc" }) + { + Services.Add(name, NewService(1)); + AssertRegistered(name); + } + AssertReserved("rpc.", () => Services.Add("rpc.", NewService(1))); + } + + [Test] + public void AddBatch_WithOneReservedEntry_LeavesTheCollectionUnchanged() + { + ServiceBinder.BindMethod(Session, "before", () => 1); + var before = Find("before"); + int count = Services.Count; + + var ex = Assert.Throws(() => ServiceBinder.BindInterface(Session, new Pair(), + new RpcInterfaceBindingOptions { NameRule = m => m.Leaf == "First" ? "ok" : "$/cancelRequest" })); + Assert.AreEqual("entries", ex.ParamName); + + Assert.AreEqual(count, Services.Count); + Assert.AreSame(before, Find("before")); + Assert.IsNull(Find("ok")); + Assert.IsNull(Find("$/cancelRequest")); + Assert.IsFalse(Services.ContainsKey("ok")); + CollectionAssert.AreEqual(new[] { "before" }, Services.Keys); + } + + [Test] + public void AddBatch_NullName_RetainsArgumentNullException() + { + var ex = Assert.Throws(() => Services.AddBatch(new NullKeyEntries())); + Assert.AreEqual(0, Services.Count); + } + + [Test] + public void AddReserved_AcceptsReservedOnce_AndKeepsTheDuplicateRule() + { + var first = NewService(1); + Services.AddReserved("rpc.discover", first); + Assert.AreSame(first, Find("rpc.discover")); + Assert.AreSame(first, Services["rpc.discover"]); + + Assert.Throws(() => Services.AddReserved("rpc.discover", NewService(2))); + Assert.AreSame(first, Find("rpc.discover"), "the first registration stands"); + Assert.AreEqual(1, Services.Count); + + Assert.Throws(() => Services.AddReserved(null, NewService(1))); + Assert.Throws(() => Services.AddReserved("rpc.other", null)); + } + } +} diff --git a/AustinHarris.JsonRpcTestN/SessionAndConfigTests.cs b/AustinHarris.JsonRpcTestN/SessionAndConfigTests.cs index 533ea73..8fb0ba7 100644 --- a/AustinHarris.JsonRpcTestN/SessionAndConfigTests.cs +++ b/AustinHarris.JsonRpcTestN/SessionAndConfigTests.cs @@ -203,9 +203,9 @@ public void Config_SetBeforeProcessHandler_IsAnAliasOfSetPreProcessHandler() int pre = 0; try { -#pragma warning disable CS0618 +#pragma warning disable CS0618, JSONRPC0001 Config.SetBeforeProcessHandler(Session, (request, context) => { pre++; return null; }); -#pragma warning restore CS0618 +#pragma warning restore CS0618, JSONRPC0001 JsonRpcProcessor.ProcessSync(Session, "{\"jsonrpc\":\"2.0\",\"method\":\"sc.ping\",\"id\":1}", null); Assert.AreEqual(1, pre); Config.SetPreProcessHandler(Session, null); @@ -250,10 +250,10 @@ public void JsonRpcService_AutoBindFalse_BindsNowhere_UntilBoundExplicitly() } finally { -#pragma warning disable CS0618 +#pragma warning disable CS0618, JSONRPC0003 Handler.DefaultHandler.UnRegisterFunction("sc.bound"); Handler.GetSessionHandler(Session).UnRegisterFunction("sc.unbound"); -#pragma warning restore CS0618 +#pragma warning restore CS0618, JSONRPC0003 } } } diff --git a/AustinHarris.JsonRpcTestN/Test.cs b/AustinHarris.JsonRpcTestN/Test.cs index f30037f..c0b4b29 100644 --- a/AustinHarris.JsonRpcTestN/Test.cs +++ b/AustinHarris.JsonRpcTestN/Test.cs @@ -95,9 +95,9 @@ public void TestCanCreateAndRemoveSession() Tuple.Create ("sooper", typeof(string)), Tuple.Create ("returns", typeof(string)) }.ToDictionary(x => x.Item1, x => x.Item2); -#pragma warning disable CS0618 +#pragma warning disable CS0618, JSONRPC0002 h.RegisterFuction("workie", metadata, new System.Collections.Generic.Dictionary(),new Func(x => "workie ... " + x)); -#pragma warning restore CS0618 +#pragma warning restore CS0618, JSONRPC0002 string request = @"{""method"":""workie"",""params"":{""sooper"":""good""},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"workie ... good\",\"id\":1}"; @@ -1614,9 +1614,9 @@ public void TestPreProcessOnSession() Tuple.Create ("sooper", typeof(string)), Tuple.Create ("returns", typeof(string)) }.ToDictionary(x => x.Item1, x => x.Item2); -#pragma warning disable CS0618 +#pragma warning disable CS0618, JSONRPC0002 h.RegisterFuction("workie", metadata, new System.Collections.Generic.Dictionary(),new Func(x => "workie ... " + x)); -#pragma warning restore CS0618 +#pragma warning restore CS0618, JSONRPC0002 string request = @"{""method"":""workie"",""params"":{""sooper"":""good""},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"workie ... good\",\"id\":1}"; @@ -1856,9 +1856,9 @@ public void TestPostProcessOnSession() Tuple.Create ("sooper", typeof(string)), Tuple.Create ("returns", typeof(string)) }.ToDictionary(x => x.Item1, x => x.Item2); -#pragma warning disable CS0618 +#pragma warning disable CS0618, JSONRPC0002 h.RegisterFuction("workie", metadata, new System.Collections.Generic.Dictionary(), new Func(x => "workie ... " + x)); -#pragma warning restore CS0618 +#pragma warning restore CS0618, JSONRPC0002 string request = @"{""method"":""workie"",""params"":{""sooper"":""good""},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"workie ... good\",\"id\":1}"; diff --git a/CHANGELOG.md b/CHANGELOG.md index 09b34b3..829b1af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ behaviour: a breaking change to either means a new major version. ### Added +- `JsonRpcLimits` and `Config.SetLimits`: the core rejects documents over 4 MiB and batches over 1024 entries with `-32600` and a `data` object naming the limit; `JsonRpcLimits.Unlimited` restores the 1.x behaviour. - `ServiceBinder.BindInterface` registers interface trees atomically, with contract naming, filtering, defaults and ownership-aware disposal (`RpcBinding`). - `ServiceBinder.BindMethod` registers any delegate as a method without attributes or a service class. - `JsonRpcProcessor.ProcessAsync` awaits `Task` and `ValueTask` methods with typed result writing, sequential batches and cooperative cancellation; `[JsonRpcCancellation]` injects the processor's token. @@ -38,12 +39,18 @@ behaviour: a breaking change to either means a new major version. - The AspNetCore host binds every registered service, `JsonRpcService` subclasses included, to its effective session (the registration's session, then `JsonRpcOptions.SessionId`, then the default). It no longer skips a subclass on the default session. - The core package's description says "no JSON library dependency" instead of "no dependencies". The session registry uses the framework's `ConcurrentDictionary`; the `NonBlocking` package reference is gone, so the core has no dependencies on `net8.0` and `net10.0` (measured with `SessionRegistryBenchmarks`: unknown-id lookups and register/destroy cycles got faster, stable lookups and dispatch are unchanged). - `SMD.Services` is an `SMDServiceCollection`; every mutation through it updates the dispatch table at once. `SMD.Types` is a process-wide registry. +- Registration refuses reserved method names (`rpc.`-prefixed and `$/cancelRequest`) on every path, including `BindMethod`, attribute binding and direct additions to `SMDServiceCollection`; `BindInterface` refused `rpc.` alone before. - The `jsonrpc` member is checked (`Config.VersionPolicy`, default `Lenient`): a missing member is accepted, `"jsonrpc":"1.0"` or a non-string value is `-32600`. - A parameter value the serializer cannot convert is `-32602` with structured data naming the parameter (it was `-32603`); `-32601` names the requested method in its data. - Named parameters are checked against the method's parameter list: an unknown or repeated name is `-32602`. - Batches: the empty-batch error is `-32600`; a batch made only of notifications produces nothing; a batch always answers with an array when it produces at least one response. - Notifications never get a wire response, whatever their outcome. - Dates and non-finite numbers are written the same way by every serializer (fraction only when non-zero, `Z`/offset/nothing by `Kind`; `NaN` and the infinities as quoted strings). +- Clarified that the WebAssembly sample is not a Native AOT or full-trimming compatibility guarantee. + +### Deprecated + +- `Config.SetBeforeProcessHandler` (`JSONRPC0001`), `Handler.RegisterFuction` (`JSONRPC0002`) and `Handler.UnRegisterFunction` (`JSONRPC0003`) are obsolete at warning level with a diagnostic id linking to [docs/obsoletions.md](docs/obsoletions.md); they stay through 2.x and are removed in 3.0. ### Removed @@ -60,6 +67,7 @@ behaviour: a breaking change to either means a new major version. ### Security +- Documents and batches are bounded in the core by default (`JsonRpcLimits`), independent of the transport. - With `Config.IncludeExceptionDetails` off (the default), an unhandled exception is answered as `-32603` with `data: null`: the exception's type name and message are no longer sent. Error handlers still receive the exception itself and can author what the client sees. The same applies to an exception thrown while writing a result. `ExceptionInfo.ForResponse` returns null when details are off. - The session registry no longer grows from untrusted session ids on the request path (see Changed). diff --git a/Json-Rpc/Config.cs b/Json-Rpc/Config.cs index 3fad4c5..d9d2d79 100644 --- a/Json-Rpc/Config.cs +++ b/Json-Rpc/Config.cs @@ -67,6 +67,23 @@ public static JsonRpcVersionPolicy VersionPolicy set { _versionPolicy = value; } } + private static volatile JsonRpcLimits _limits = JsonRpcLimits.Default; + + /// The process-wide document and batch limits, used when a session has no override. + public static JsonRpcLimits Limits => _limits; + + /// Sets the process-wide document and batch limits. Null is not allowed. + public static void SetLimits(JsonRpcLimits limits) + { + _limits = limits ?? throw new ArgumentNullException(nameof(limits)); + } + + /// Sets one session's limits; null makes it inherit . Creates the session if needed. + public static void SetLimits(string sessionId, JsonRpcLimits limits) + { + Handler.GetSessionHandler(sessionId).Limits = limits; + } + /// Sets the version policy for one session; null makes the session follow . public static void SetVersionPolicy(string sessionId, JsonRpcVersionPolicy? policy) { @@ -124,7 +141,11 @@ public static void SetPostProcessHandler(string sessionId, PostProcessHandler ha } /// The former name of . - [Obsolete("Use SetPreProcessHandler(sessionId, handler).")] +#if NET5_0_OR_GREATER + [Obsolete(Obsoletions.SetBeforeProcessHandlerMessage, DiagnosticId = Obsoletions.SetBeforeProcessHandlerDiagId, UrlFormat = Obsoletions.SharedUrlFormat)] +#else + [Obsolete(Obsoletions.SetBeforeProcessHandlerDiagId + ": " + Obsoletions.SetBeforeProcessHandlerMessage)] +#endif public static void SetBeforeProcessHandler(string sessionId, PreProcessHandler handler) { SetPreProcessHandler(sessionId, handler); diff --git a/Json-Rpc/Handler.cs b/Json-Rpc/Handler.cs index 7933d00..3a80a1c 100644 --- a/Json-Rpc/Handler.cs +++ b/Json-Rpc/Handler.cs @@ -151,6 +151,15 @@ public void Destroy() /// public JsonRpcVersionPolicy? VersionPolicy { get; set; } + private volatile JsonRpcLimits _limits; + + /// The limits for this session. Null inherits . + public JsonRpcLimits Limits + { + get { return _limits; } + set { _limits = value; } + } + /// /// Provides access to a context specific to each JsonRpc method invocation. /// Warning: Must be called from within the execution context of the jsonRpc Method to return the context @@ -274,13 +283,21 @@ public static void RegisterInstance(string sessionId, object instance) /// The parameter names and types that will be positionally bound to the function; the last entry is the return type /// Optional default values for parameters /// A reference to the Function - [Obsolete("Use ServiceBinder.BindMethod; unlike RegisterFuction it throws when the name is already registered instead of replacing it.")] +#if NET5_0_OR_GREATER + [Obsolete(Obsoletions.RegisterFuctionMessage, DiagnosticId = Obsoletions.RegisterFuctionDiagId, UrlFormat = Obsoletions.SharedUrlFormat)] +#else + [Obsolete(Obsoletions.RegisterFuctionDiagId + ": " + Obsoletions.RegisterFuctionMessage)] +#endif public void RegisterFuction(string methodName, Dictionary parameterNameTypeMapping, Dictionary parameterNameDefaultValueMapping, Delegate implementation) { MetaData.AddService(methodName, parameterNameTypeMapping, parameterNameDefaultValueMapping ?? new Dictionary(), implementation); } - [Obsolete("Use ServiceBinder.UnbindMethod.")] +#if NET5_0_OR_GREATER + [Obsolete(Obsoletions.UnRegisterFunctionMessage, DiagnosticId = Obsoletions.UnRegisterFunctionDiagId, UrlFormat = Obsoletions.SharedUrlFormat)] +#else + [Obsolete(Obsoletions.UnRegisterFunctionDiagId + ": " + Obsoletions.UnRegisterFunctionMessage)] +#endif public void UnRegisterFunction(string methodName) { MetaData.RemoveService(methodName); @@ -816,6 +833,9 @@ private static void WriteErrorData(IBufferWriter output, JsonRpcSerializer case MethodNotFoundInfo notFound: notFound.WriteTo(output); break; + case LimitExceededInfo limitExceeded: + limitExceeded.WriteTo(output); + break; case ParameterErrorInfo parameterError: parameterError.WriteTo(output); break; diff --git a/Json-Rpc/JsonRpcLimits.cs b/Json-Rpc/JsonRpcLimits.cs new file mode 100644 index 0000000..6a837b4 --- /dev/null +++ b/Json-Rpc/JsonRpcLimits.cs @@ -0,0 +1,33 @@ +using System; + +namespace AustinHarris.JsonRpc +{ + /// + /// Immutable bounds on the UTF-8 bytes in one JSON-RPC document and the number of top-level elements in a + /// batch. A zero value disables that bound. A transport's own byte limit, such as Kestrel's + /// MaxRequestBytes, is checked first when present. + /// + public sealed class JsonRpcLimits + { + /// Creates document and batch limits. Negative values are invalid; zero disables a limit. + public JsonRpcLimits(long maxDocumentBytes = 4 * 1024 * 1024, int maxBatchCount = 1024) + { + if (maxDocumentBytes < 0) throw new ArgumentOutOfRangeException(nameof(maxDocumentBytes)); + if (maxBatchCount < 0) throw new ArgumentOutOfRangeException(nameof(maxBatchCount)); + MaxDocumentBytes = maxDocumentBytes; + MaxBatchCount = maxBatchCount; + } + + /// The maximum UTF-8 bytes in one document, or zero for no byte limit. + public long MaxDocumentBytes { get; } + + /// The maximum number of top-level batch elements, or zero for no batch limit. + public int MaxBatchCount { get; } + + /// The default bounds: 4 MiB per document and 1024 elements per batch. + public static JsonRpcLimits Default { get; } = new JsonRpcLimits(); + + /// No core document-byte or batch-count bound. + public static JsonRpcLimits Unlimited { get; } = new JsonRpcLimits(0, 0); + } +} diff --git a/Json-Rpc/JsonRpcProcessor.Async.cs b/Json-Rpc/JsonRpcProcessor.Async.cs index 564ed31..a163b2d 100644 --- a/Json-Rpc/JsonRpcProcessor.Async.cs +++ b/Json-Rpc/JsonRpcProcessor.Async.cs @@ -17,7 +17,21 @@ public static partial class JsonRpcProcessor public static Task ProcessAsync(string sessionId, ReadOnlySequence request, IBufferWriter output, object context = null, JsonRpcSerializer serializer = null, CancellationToken cancellationToken = default) { - if (request.IsSingleSegment) return ProcessAsync(sessionId, request.First, output, context, serializer, cancellationToken); + if (request.IsSingleSegment) + return StartAsyncDocument(sessionId, request.First, output, context, serializer, cancellationToken); + Handler handler; + JsonRpcLimits limits; + try + { + cancellationToken.ThrowIfCancellationRequested(); + handler = GetRequestHandler(sessionId); + limits = handler.Limits ?? Config.Limits; + if (ExceedsDocumentLimit(request.Length, limits)) + return RejectAsyncDocument(handler, serializer, output, limits.MaxDocumentBytes, + handler.HasParseErrorHandler ? Utf8Json.ToStringUtf8(request.ToArray()) : null, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { return Task.FromCanceled(cancellationToken); } + catch (Exception ex) { return Task.FromException(ex); } var scratch = AsyncScratch.Rent(); int length; byte[] buffer; @@ -28,7 +42,8 @@ public static Task ProcessAsync(string sessionId, ReadOnlySequence request request.CopyTo(buffer); } catch { scratch.Return(); throw; } - return StartAsyncDocument(sessionId, new ReadOnlyMemory(buffer, 0, length), output, context, serializer, cancellationToken, scratch); + return StartAsyncDocument(sessionId, new ReadOnlyMemory(buffer, 0, length), output, context, + serializer, cancellationToken, handler, limits, scratch); } /// @@ -38,7 +53,7 @@ public static Task ProcessAsync(string sessionId, ReadOnlySequence request public static Task ProcessAsync(string sessionId, ReadOnlyMemory request, IBufferWriter output, object context = null, JsonRpcSerializer serializer = null, CancellationToken cancellationToken = default) { - return StartAsyncDocument(sessionId, request, output, context, serializer, cancellationToken, AsyncScratch.Rent()); + return StartAsyncDocument(sessionId, request, output, context, serializer, cancellationToken); } /// @@ -48,6 +63,19 @@ public static Task ProcessAsync(string sessionId, ReadOnlyMemory request, public static Task ProcessAsync(string sessionId, ReadOnlySpan request, IBufferWriter output, object context = null, JsonRpcSerializer serializer = null, CancellationToken cancellationToken = default) { + Handler handler; + JsonRpcLimits limits; + try + { + cancellationToken.ThrowIfCancellationRequested(); + handler = GetRequestHandler(sessionId); + limits = handler.Limits ?? Config.Limits; + if (ExceedsDocumentLimit(request.Length, limits)) + return RejectAsyncDocument(handler, serializer, output, limits.MaxDocumentBytes, + handler.HasParseErrorHandler ? Utf8Json.ToStringUtf8(request) : null, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { return Task.FromCanceled(cancellationToken); } + catch (Exception ex) { return Task.FromException(ex); } var scratch = AsyncScratch.Rent(); byte[] buffer; try @@ -56,17 +84,31 @@ public static Task ProcessAsync(string sessionId, ReadOnlySpan request, IB request.CopyTo(buffer); } catch { scratch.Return(); throw; } - return StartAsyncDocument(sessionId, new ReadOnlyMemory(buffer, 0, request.Length), output, context, serializer, cancellationToken, scratch); + return StartAsyncDocument(sessionId, new ReadOnlyMemory(buffer, 0, request.Length), output, + context, serializer, cancellationToken, handler, limits, scratch); } /// Processes a string asynchronously on the selected session, returning an empty string for notifications. public static async Task ProcessAsync(string sessionId, string jsonRpc, object context = null, JsonRpcSerializer serializer = null, CancellationToken cancellationToken = default) { - var input = Encoding.UTF8.GetBytes(jsonRpc); + cancellationToken.ThrowIfCancellationRequested(); + var handler = GetRequestHandler(sessionId); + var limits = handler.Limits ?? Config.Limits; + int length = Encoding.UTF8.GetByteCount(jsonRpc); using (var output = new PooledByteBufferWriter()) { - await ProcessAsync(sessionId, new ReadOnlyMemory(input), output, context, serializer, cancellationToken).ConfigureAwait(false); + if (ExceedsDocumentLimit(length, limits)) + { + await RejectAsyncDocument(handler, serializer, output, limits.MaxDocumentBytes, + handler.HasParseErrorHandler ? jsonRpc : null, cancellationToken).ConfigureAwait(false); + } + else + { + var input = Encoding.UTF8.GetBytes(jsonRpc); + await StartAsyncDocument(sessionId, new ReadOnlyMemory(input), output, context, + serializer, cancellationToken, handler, limits).ConfigureAwait(false); + } return output.ToString(); } } @@ -78,13 +120,22 @@ public static Task ProcessAsync(string jsonRpc, object context = null, C } private static Task StartAsyncDocument(string sessionId, ReadOnlyMemory document, IBufferWriter destination, - object context, JsonRpcSerializer serializer, CancellationToken token, AsyncScratch scratch) + object context, JsonRpcSerializer serializer, CancellationToken token, Handler handler = null, + JsonRpcLimits limits = null, AsyncScratch scratch = null) { bool transferred = false; try { token.ThrowIfCancellationRequested(); - if (!Handler.TryGetSessionHandler(sessionId, out var handler)) handler = Handler.UnknownSessionHandler; + if (handler == null) + { + handler = GetRequestHandler(sessionId); + limits = handler.Limits ?? Config.Limits; + } + if (ExceedsDocumentLimit(document.Length, limits)) + return RejectAsyncDocument(handler, serializer, destination, limits.MaxDocumentBytes, + handler.HasParseErrorHandler ? Utf8Json.ToStringUtf8(document.Span) : null, token); + scratch = scratch ?? AsyncScratch.Rent(); serializer = serializer ?? handler.Serializer ?? Config.Serializer; scratch.DocumentLength = document.Length; var reader = scratch.GetReader(serializer); @@ -106,6 +157,11 @@ private static Task StartAsyncDocument(string sessionId, ReadOnlyMemory do } pending.GetAwaiter().GetResult(); } + else if (limits.MaxBatchCount != 0 && reader.Count > limits.MaxBatchCount) + { + WriteLimitError(output, handler, serializer, "maxBatchCount", limits.MaxBatchCount, + handler.HasParseErrorHandler ? Utf8Json.ToStringUtf8(document.Span) : null); + } else if (reader.Count == 0) { var ex = new JsonRpcException(-32600, "Invalid Request", "Batch of calls was empty."); @@ -145,7 +201,25 @@ private static Task StartAsyncDocument(string sessionId, ReadOnlyMemory do { return Task.FromException(ex); } - finally { if (!transferred) scratch.Return(); } + finally { if (!transferred) scratch?.Return(); } + } + + private static Task RejectAsyncDocument(Handler handler, JsonRpcSerializer serializer, IBufferWriter destination, + long maximum, string rawDocument, CancellationToken token) + { + AsyncScratch scratch = null; + try + { + token.ThrowIfCancellationRequested(); + scratch = AsyncScratch.Rent(); + WriteLimitError(scratch.Output, handler, serializer ?? handler.Serializer ?? Config.Serializer, + "maxDocumentBytes", maximum, rawDocument); + CommitAsyncDocument(scratch, destination, token); + return Task.CompletedTask; + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { return Task.FromCanceled(token); } + catch (Exception ex) { return Task.FromException(ex); } + finally { scratch?.Return(); } } private static async Task FinishSingleDocumentAsync(ValueTask pending, AsyncScratch scratch, IBufferWriter destination, CancellationToken token) diff --git a/Json-Rpc/JsonRpcProcessor.cs b/Json-Rpc/JsonRpcProcessor.cs index cd5b530..47be7ea 100644 --- a/Json-Rpc/JsonRpcProcessor.cs +++ b/Json-Rpc/JsonRpcProcessor.cs @@ -19,9 +19,17 @@ public static partial class JsonRpcProcessor /// Processes one document (a request or a batch). Writes the response bytes to ; writes nothing for notifications. public static void Process(string sessionId, in ReadOnlySequence request, IBufferWriter output, object context = null, JsonRpcSerializer serializer = null) { + var handler = GetRequestHandler(sessionId); + var limits = handler.Limits ?? Config.Limits; + if (ExceedsDocumentLimit(request.Length, limits)) + { + RejectDocument(handler, serializer, output, limits.MaxDocumentBytes, + handler.HasParseErrorHandler ? Utf8Json.ToStringUtf8(request.ToArray()) : null); + return; + } if (request.IsSingleSegment) { - Process(sessionId, request.First, output, context, serializer); + ProcessMemory(handler, limits, request.First, output, context, serializer); return; } int length = checked((int)request.Length); @@ -30,7 +38,7 @@ public static void Process(string sessionId, in ReadOnlySequence request, { var buffer = scratch.Input(length); request.CopyTo(buffer); - ProcessCore(sessionId, new ReadOnlyMemory(buffer, 0, length), output, context, serializer, scratch); + ProcessCore(handler, limits, new ReadOnlyMemory(buffer, 0, length), output, context, serializer, scratch); } finally { @@ -40,11 +48,25 @@ public static void Process(string sessionId, in ReadOnlySequence request, /// Processes one document held in memory. The memory must stay valid until the call returns. public static void Process(string sessionId, ReadOnlyMemory request, IBufferWriter output, object context = null, JsonRpcSerializer serializer = null) + { + var handler = GetRequestHandler(sessionId); + var limits = handler.Limits ?? Config.Limits; + if (ExceedsDocumentLimit(request.Length, limits)) + { + RejectDocument(handler, serializer, output, limits.MaxDocumentBytes, + handler.HasParseErrorHandler ? Utf8Json.ToStringUtf8(request.Span) : null); + return; + } + ProcessMemory(handler, limits, request, output, context, serializer); + } + + private static void ProcessMemory(Handler handler, JsonRpcLimits limits, ReadOnlyMemory request, + IBufferWriter output, object context, JsonRpcSerializer serializer) { var scratch = Scratch.Rent(); try { - ProcessCore(sessionId, request, output, context, serializer, scratch); + ProcessCore(handler, limits, request, output, context, serializer, scratch); } finally { @@ -55,12 +77,20 @@ public static void Process(string sessionId, ReadOnlyMemory request, IBuff /// Processes one document from a span (copied into a pooled buffer). public static void Process(string sessionId, ReadOnlySpan request, IBufferWriter output, object context = null, JsonRpcSerializer serializer = null) { + var handler = GetRequestHandler(sessionId); + var limits = handler.Limits ?? Config.Limits; + if (ExceedsDocumentLimit(request.Length, limits)) + { + RejectDocument(handler, serializer, output, limits.MaxDocumentBytes, + handler.HasParseErrorHandler ? Utf8Json.ToStringUtf8(request) : null); + return; + } var scratch = Scratch.Rent(); try { var buffer = scratch.Input(request.Length); request.CopyTo(buffer); - ProcessCore(sessionId, new ReadOnlyMemory(buffer, 0, request.Length), output, context, serializer, scratch); + ProcessCore(handler, limits, new ReadOnlyMemory(buffer, 0, request.Length), output, context, serializer, scratch); } finally { @@ -71,6 +101,22 @@ public static void Process(string sessionId, ReadOnlySpan request, IBuffer /// Processes a UTF-8 document and returns the UTF-8 response (empty for notifications). public static byte[] ProcessBytes(string sessionId, ReadOnlySpan request, object context = null, JsonRpcSerializer serializer = null) { + var handler = GetRequestHandler(sessionId); + var limits = handler.Limits ?? Config.Limits; + if (ExceedsDocumentLimit(request.Length, limits)) + { + var rejected = Scratch.Rent(); + try + { + var error = rejected.Output; + error.Clear(); + WriteLimitError(error, handler, serializer ?? handler.Serializer ?? Config.Serializer, + "maxDocumentBytes", limits.MaxDocumentBytes, + handler.HasParseErrorHandler ? Utf8Json.ToStringUtf8(request) : null); + return error.ToArray(); + } + finally { rejected.Return(); } + } var scratch = Scratch.Rent(); try { @@ -78,7 +124,7 @@ public static byte[] ProcessBytes(string sessionId, ReadOnlySpan request, request.CopyTo(buffer); var output = scratch.Output; output.Clear(); - ProcessCore(sessionId, new ReadOnlyMemory(buffer, 0, request.Length), output, context, serializer, scratch, true); + ProcessCore(handler, limits, new ReadOnlyMemory(buffer, 0, request.Length), output, context, serializer, scratch, true); return output.ToArray(); } finally @@ -143,15 +189,30 @@ public static string ProcessSync(JsonRpcSerializer serializer, string jsonRpc, o // (null converts to string better than to object) with a null document. public static string ProcessSync(string sessionId, string jsonRpc, object jsonRpcContext, JsonRpcSerializer serializer = null) { + var handler = GetRequestHandler(sessionId); + var limits = handler.Limits ?? Config.Limits; + int length = Encoding.UTF8.GetByteCount(jsonRpc); + if (ExceedsDocumentLimit(length, limits)) + { + var rejected = Scratch.Rent(); + try + { + var error = rejected.Output; + error.Clear(); + WriteLimitError(error, handler, serializer ?? handler.Serializer ?? Config.Serializer, + "maxDocumentBytes", limits.MaxDocumentBytes, handler.HasParseErrorHandler ? jsonRpc : null); + return error.ToString(); + } + finally { rejected.Return(); } + } var scratch = Scratch.Rent(); try { - int max = Encoding.UTF8.GetMaxByteCount(jsonRpc.Length); - var buffer = scratch.Input(max); - int length = Encoding.UTF8.GetBytes(jsonRpc, 0, jsonRpc.Length, buffer, 0); + var buffer = scratch.Input(length); + Encoding.UTF8.GetBytes(jsonRpc, 0, jsonRpc.Length, buffer, 0); var output = scratch.Output; output.Clear(); - ProcessCore(sessionId, new ReadOnlyMemory(buffer, 0, length), output, jsonRpcContext, serializer, scratch, true); + ProcessCore(handler, limits, new ReadOnlyMemory(buffer, 0, length), output, jsonRpcContext, serializer, scratch, true); return output.ToString(); } finally @@ -162,9 +223,8 @@ public static string ProcessSync(string sessionId, string jsonRpc, object jsonRp // ------------------------------------------------------------------ core - private static void ProcessCore(string sessionId, ReadOnlyMemory document, IBufferWriter destination, object context, JsonRpcSerializer serializer, Scratch scratch, bool destinationIsScratch = false) + private static void ProcessCore(Handler handler, JsonRpcLimits limits, ReadOnlyMemory document, IBufferWriter destination, object context, JsonRpcSerializer serializer, Scratch scratch, bool destinationIsScratch = false) { - if (!Handler.TryGetSessionHandler(sessionId, out var handler)) handler = Handler.UnknownSessionHandler; serializer = serializer ?? handler.Serializer ?? Config.Serializer; // Always render into the rewindable scratch buffer, then hand the bytes to the caller's writer. @@ -184,6 +244,11 @@ private static void ProcessCore(string sessionId, ReadOnlyMemory document, { handler.HandleRequest(reader, 0, serializer, output, context); } + else if (limits.MaxBatchCount != 0 && reader.Count > limits.MaxBatchCount) + { + WriteLimitError(output, handler, serializer, "maxBatchCount", limits.MaxBatchCount, + handler.HasParseErrorHandler ? Utf8Json.ToStringUtf8(document.Span) : null); + } else if (reader.Count == 0) { var ex = new JsonRpcException(-32600, "Invalid Request", "Batch of calls was empty."); @@ -225,6 +290,39 @@ private static void ProcessCore(string sessionId, ReadOnlyMemory document, } } + private static Handler GetRequestHandler(string sessionId) + { + return Handler.TryGetSessionHandler(sessionId, out var handler) ? handler : Handler.UnknownSessionHandler; + } + + private static bool ExceedsDocumentLimit(long length, JsonRpcLimits limits) + { + return limits.MaxDocumentBytes != 0 && length > limits.MaxDocumentBytes; + } + + private static void WriteLimitError(PooledByteBufferWriter output, Handler handler, JsonRpcSerializer serializer, + string limit, long maximum, string rawDocument) + { + var error = new JsonRpcException(-32600, "Invalid Request", new LimitExceededInfo(limit, maximum)); + if (handler.HasParseErrorHandler) error = handler.ProcessParseException(rawDocument, error); + Handler.WriteErrorEnvelope(output, serializer, error, default); + } + + private static void RejectDocument(Handler handler, JsonRpcSerializer serializer, IBufferWriter destination, + long maximum, string rawDocument) + { + var scratch = Scratch.Rent(); + try + { + var output = scratch.Output; + output.Clear(); + WriteLimitError(output, handler, serializer ?? handler.Serializer ?? Config.Serializer, + "maxDocumentBytes", maximum, rawDocument); + output.CopyTo(destination); + } + finally { scratch.Return(); } + } + /// Per-thread pooled buffers and a cached reader. Re-entrant calls get a fresh instance. private sealed class Scratch { diff --git a/Json-Rpc/Obsoletions.cs b/Json-Rpc/Obsoletions.cs new file mode 100644 index 0000000..7c37fee --- /dev/null +++ b/Json-Rpc/Obsoletions.cs @@ -0,0 +1,17 @@ +namespace AustinHarris.JsonRpc +{ + // JSONRPC0xxx is reserved for obsoletions; JSONRPC1xxx is reserved for generator diagnostics. IDs are never reused. + internal static class Obsoletions + { + internal const string SharedUrlFormat = "https://astn.github.io/JSON-RPC.NET/obsoletions.html#{0}"; + + internal const string SetBeforeProcessHandlerMessage = "Use SetPreProcessHandler(sessionId, handler)."; + internal const string SetBeforeProcessHandlerDiagId = "JSONRPC0001"; + + internal const string RegisterFuctionMessage = "Use ServiceBinder.BindMethod; unlike RegisterFuction it throws when the name is already registered instead of replacing it."; + internal const string RegisterFuctionDiagId = "JSONRPC0002"; + + internal const string UnRegisterFunctionMessage = "Use ServiceBinder.UnbindMethod."; + internal const string UnRegisterFunctionDiagId = "JSONRPC0003"; + } +} diff --git a/Json-Rpc/SMDService.cs b/Json-Rpc/SMDService.cs index 5f07a27..cfe024d 100644 --- a/Json-Rpc/SMDService.cs +++ b/Json-Rpc/SMDService.cs @@ -109,7 +109,8 @@ private static string TypeHash(Dictionary jo) /// The services of one session keyed by JSON method name. A dictionary for callers; underneath, every /// mutation also replaces the lock-free UTF-8 dispatch table the request path resolves methods from, so /// an added, removed or replaced service is visible to the next request. Reads of the dictionary take a - /// lock; the request path never does. + /// lock; the request path never does. Names beginning with rpc. and the name $/cancelRequest + /// are reserved: every public way of adding a service refuses them with an . /// public sealed class SMDServiceCollection : IDictionary, IReadOnlyDictionary { @@ -117,11 +118,27 @@ public sealed class SMDServiceCollection : IDictionary, IRea private readonly Utf8KeyTable _table = new Utf8KeyTable(); private readonly object _sync = new object(); + private const string ReservedPrefix = "rpc."; + private const string CancelRequest = "$/cancelRequest"; + + /// + /// Refuses the names the specification reserves (rpc.-prefixed, ordinal and case-sensitive) and + /// $/cancelRequest. The name is compared as given: no trimming and no case folding. + /// + private static void ThrowIfReserved(string name, string paramName) + { + if (name == null) throw new ArgumentNullException(paramName); + if (name.StartsWith(ReservedPrefix, StringComparison.Ordinal) || string.Equals(name, CancelRequest, StringComparison.Ordinal)) + throw new ArgumentException("'" + name + "' is a reserved JSON-RPC method name.", paramName); + } + internal void AddBatch(IReadOnlyDictionary entries) { if (entries.Count == 0) return; lock (_sync) { + // Every name is checked before anything is copied or added, so a batch with one reserved name adds nothing. + foreach (var entry in entries) ThrowIfReserved(entry.Key, nameof(entries)); var next = new Dictionary(_services); foreach (var entry in entries) { @@ -173,6 +190,7 @@ public SMDService this[string key] { if (key == null) throw new ArgumentNullException(nameof(key)); if (value == null) throw new ArgumentNullException(nameof(value)); + ThrowIfReserved(key, nameof(key)); lock (_sync) { _services[key] = value; @@ -222,11 +240,30 @@ public ICollection Values IEnumerable IReadOnlyDictionary.Values => Values; public void Add(string key, SMDService value) + { + if (key == null) throw new ArgumentNullException(nameof(key)); + if (value == null) throw new ArgumentNullException(nameof(value)); + ThrowIfReserved(key, nameof(key)); + lock (_sync) + { + _services.Add(key, value); + _table.Set(key, value); + } + } + + /// + /// Adds a service under a reserved name, for the library's own rpc.discover registration. + /// It bypasses only the reserved-name check: an existing still throws + /// . Internal, so the reserved check is the only public path; unused in 2.0.0. + /// + internal void AddReserved(string key, SMDService value) { if (key == null) throw new ArgumentNullException(nameof(key)); if (value == null) throw new ArgumentNullException(nameof(value)); lock (_sync) { + if (_services.ContainsKey(key)) + throw new ArgumentException("JSON-RPC method '" + key + "' is already registered.", nameof(key)); _services.Add(key, value); _table.Set(key, value); } diff --git a/Json-Rpc/Serialization/ErrorInfo.cs b/Json-Rpc/Serialization/ErrorInfo.cs index 8274760..b32df19 100644 --- a/Json-Rpc/Serialization/ErrorInfo.cs +++ b/Json-Rpc/Serialization/ErrorInfo.cs @@ -4,6 +4,36 @@ namespace AustinHarris.JsonRpc.Serialization { + /// The structured data of a -32600 document-byte or batch-count limit error. + public sealed class LimitExceededInfo + { + private static readonly byte[] Prefix = Encoding.ASCII.GetBytes("{\"limit\":"); + private static readonly byte[] MaximumKey = Encoding.ASCII.GetBytes(",\"maximum\":"); + + /// Creates the error data with the limit's name and configured maximum. + public LimitExceededInfo(string limit, long maximum) + { + Limit = limit; + Maximum = maximum; + } + + /// maxDocumentBytes or maxBatchCount. + public string Limit { get; } + + /// The configured maximum that was exceeded. + public long Maximum { get; } + + /// Writes the same JSON data bytes for every serializer. + public void WriteTo(IBufferWriter output) + { + Utf8Json.WriteRaw(output, Prefix); + Utf8Json.WriteString(output, Limit); + Utf8Json.WriteRaw(output, MaximumKey); + Utf8Json.WriteInt64(output, Maximum); + Utf8Json.WriteByte(output, (byte)'}'); + } + } + /// /// The data of a -32601 error: {"method":"name"}, the effective method name (decoded, and as /// replaced by a pre-process handler). Written the same way by every serializer. Nothing else is disclosed: the diff --git a/Json-Rpc/ServiceBinder.Interface.cs b/Json-Rpc/ServiceBinder.Interface.cs index 90c7ec3..7bf8209 100644 --- a/Json-Rpc/ServiceBinder.Interface.cs +++ b/Json-Rpc/ServiceBinder.Interface.cs @@ -21,8 +21,8 @@ public static RpcBinding BindInterface(TInterface implementation, Rp /// Only public instance methods declared by the selected interfaces are exported; names, attributes, /// and optional defaults come from those declarations, including explicit implementations. /// Recursive getters run once per mount at registration and may have side effects. A failure publishes - /// nothing; getter side effects cannot be undone. Empty, reserved rpc., duplicate, and occupied - /// names are rejected. Generic methods and default interface bodies are unsupported. + /// nothing; getter side effects cannot be undone. Empty, reserved (rpc.-prefixed or $/cancelRequest), + /// duplicate, and occupied names are rejected. Generic methods and default interface bodies are unsupported. /// The returned handle owns the registrations, not the lifetime of the implementation objects. /// public static RpcBinding BindInterface(string sessionId, TInterface implementation, @@ -118,8 +118,8 @@ private void AddMethod(MethodInfo method, object target, string[] path, string a var description = new RpcInterfaceMethod(method, path, leaf, defaultName); if (_include != null && !_include(description)) return; string name = _nameRule == null ? defaultName : _nameRule(description); - if (string.IsNullOrWhiteSpace(name) || name.StartsWith("rpc.", StringComparison.Ordinal)) - throw new ArgumentException("Invalid or reserved JSON-RPC interface method name: '" + name + "'."); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Invalid JSON-RPC interface method name: '" + name + "'."); if (Entries.ContainsKey(name)) throw new ArgumentException("Duplicate JSON-RPC interface method name '" + name + "'."); if (method.ContainsGenericParameters) throw new ArgumentException("Generic interface method '" + method.Name + "' is not supported."); diff --git a/README.md b/README.md index 51d8cd1..740d60c 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ The "Covers" column is what each target framework admits, not what is tested. CI Dependencies at 2.0.0: none on `net8.0` and `net10.0`; on `netstandard` only, `System.Memory` 4.6.3, and `netstandard2.0` also references `System.Threading.Tasks.Extensions` 4.5.0 for `ValueTask`. The Newtonsoft package depends on Newtonsoft.Json 13.0.4 and the System.Text.Json package on System.Text.Json 10.0.3. -The core uses no reflection emit, so it runs under the WebAssembly interpreter and under WebAssembly AOT (see [samples/WasmHost](samples/WasmHost)). It is not annotated for trimming: services and their `[JsonRpcMethod]` members are found by reflection, so keep those types rooted if you publish trimmed. +The core uses no reflection emit. The WebAssembly sample runs under the configurations listed in [samples/WasmHost/README.md](samples/WasmHost/README.md); it validates neither `PublishTrimmed` nor `PublishAot`, which are unsupported: services and their `[JsonRpcMethod]` members are found by reflection and the invokers are compiled expression trees. ## Installation @@ -177,7 +177,7 @@ That is the whole in-process server. The rest of this page is about exposing met ## Defining methods -A *method* is a callable identified by the `method` member of a request; its implementation is a delegate, a `[JsonRpcMethod]` member of a class, or a member of a bound interface. `ServiceBinder` never asks for a `MethodInfo`; the same word names the -32601 "Method not found" error. +A *method* is a callable identified by the `method` member of a request; its implementation is a delegate, a `[JsonRpcMethod]` member of a class, or a member of a bound interface. `ServiceBinder` never asks for a `MethodInfo`; the same word names the -32601 "Method not found" error. Names beginning with `rpc.` and the name `$/cancelRequest` are reserved and refused at registration. ### Classes @@ -248,6 +248,39 @@ The core is transport-agnostic. Pick whichever of these fits, or build your own Call the processor yourself, as in [Getting started](#getting-started). The byte overloads take what a `PipeReader` gives you (`ReadOnlySequence`) and write to any `IBufferWriter`: a `PipeWriter`, a socket buffer or `HttpResponse.BodyWriter`. Nothing is written for a notification, so check `output.WrittenCount` before sending. If your transport carries several documents per connection, `JsonFramer.TryReadDocument` cuts complete documents out of the byte stream without parsing them. +A host that owns its transport also owns the deadline (the core has none; see [Deadlines](#asynchronous-methods-and-cancellation)). Link a `CancellationTokenSource` to the connection's lifetime token, arm it with `CancelAfter` and pass its token to `ProcessAsync` and to the write of the reply. When the budget expires, abort the transport at once, but still await the call: `ProcessAsync` completes only after the running method has terminated, a cancelled call commits no response bytes, and until the await returns the request memory and the output writer belong to the call, so neither goes back to a pool or is reused before then. + +```csharp +// One request document on a connection the host owns: `rented` came from ArrayPool.Shared, +// `transport` is the connection's stream, `lifetime` is cancelled when the connection closes. +static async Task ServeDocumentAsync(string sessionId, byte[] rented, int length, Stream transport, + CancellationToken lifetime) +{ + var output = new ArrayBufferWriter(); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime); + deadline.CancelAfter(TimeSpan.FromSeconds(5)); + // When the budget expires, abort the transport at once, even while a method is still running. + using var abort = deadline.Token.Register(static s => ((Stream)s!).Dispose(), transport); + try + { + await JsonRpcProcessor.ProcessAsync(sessionId, new ReadOnlyMemory(rented, 0, length), output, + context: transport, serializer: null, cancellationToken: deadline.Token); + } + catch (OperationCanceledException) + { + return; // the call has terminated and wrote no response bytes + } + finally + { + // Only now that the awaited call has returned may the input go back to the pool + // (or the output be reused): until then the processor may still read and write them. + ArrayPool.Shared.Return(rented); + } + if (output.WrittenCount > 0) // nothing is written for a notification + await transport.WriteAsync(output.WrittenMemory, deadline.Token); +} +``` + ### Kestrel HTTP endpoint ```csharp @@ -279,7 +312,7 @@ builder.WebHost.ConfigureKestrel(k => Clients write JSON documents back to back (whitespace or newlines between them are fine) and read the responses in the same order, also back to back with no separator; notifications produce nothing. The framer accepts strict JSON only, so single-quoted strings and other lenient syntax are refused on a raw connection even with the Json.NET serializer. -A raw connection has no authentication, authorisation or rate limiting; those are HTTP middleware and do not run here. Listen on loopback or a Unix socket, or put something in front that authenticates. A document larger than `MaxRequestBytes` (4 MB) aborts the connection. +A raw connection has no authentication, authorisation or rate limiting; those are HTTP middleware and do not run here. Listen on loopback or a Unix socket, or put something in front that authenticates. A document larger than `MaxRequestBytes` (4 MB) aborts the connection. The core applies its own `JsonRpcLimits` to the document the host hands over, so the transport limit is met first and the core limit second. With `EnableAsyncMethods = true`, documents on one connection are processed one at a time in order, and replies already finished are flushed before the connection waits on a slow method. Separate connections run concurrently. @@ -333,6 +366,7 @@ The errors the library raises itself carry structured `data`, identical for ever | Code | `error.data` | Object seen by the error handler | | --- | --- | --- | | `-32601` Method not found | `{"method":""}` | `MethodNotFoundInfo` | +| `-32600` Invalid Request: the document or batch exceeds a configured limit | `{"limit":"maxDocumentBytes","maximum":4194304}` or `{"limit":"maxBatchCount","maximum":1024}` (the configured maximum) | `LimitExceededInfo` | | `-32602` Invalid params: count, missing, unknown or repeated named parameter | a sentence, e.g. `"Named parameter 'b' was not present."` | `string` | | `-32602` Invalid params: a value the serializer could not convert | `{"reason":"conversion","parameter":"b","index":1,"expectedType":"int32"}` plus `"message"` when `Config.IncludeExceptionDetails` is on; the value sent is never echoed | `ParameterErrorInfo` (with the serializer's exception in `Cause`) | | `-32603` Internal error: the method threw, its result could not be written, or a parameter's type is one the serializer cannot handle | `null`, or the full `ExceptionInfo` when `Config.IncludeExceptionDetails` is on, see [Exception disclosure](#exception-disclosure) | `Exception` | @@ -358,6 +392,8 @@ string response = await JsonRpcProcessor.ProcessAsync(sessionId, requestJson, **Which entry point to use.** `ProcessAsync` is the only entry point that awaits. `Process` and `ProcessSync` answer an async method with `-32603` and a message pointing to `ProcessAsync`, and do not call it. The older `Task Process(…)` overloads run the synchronous path on the thread pool through `Task.Factory.StartNew`; despite returning a `Task`, they do not await async methods either. +**Deadlines.** The server defines no per-call deadline and no client-supplied deadline member. On HTTP, configure the ASP.NET Core request-timeouts middleware (`AddRequestTimeouts`, `UseRequestTimeouts`, `WithRequestTimeout`) on the endpoint; its budget covers the whole HTTP request including a batch, it is observed only with `EnableAsyncMethods = true` (the synchronous endpoint path passes no token), and only by `[JsonRpcCancellation]` parameters and by the processor between and after batch elements, so a completed result can be discarded without the method having observed a token. Raw and in-process hosts own their lifetime tokens; application methods own finer operation budgets. Cancellation is cooperative: the library waits for running methods and does not undo their effects. A host that owns its transport enforces a deadline itself; [In-process (strings or bytes)](#in-process-strings-or-bytes) shows one. + **Order.** A batch runs one request at a time, in order. Notifications are awaited like any other request. **Cancellation.** To receive the processor's token, a method declares a `CancellationToken` parameter marked `[JsonRpcCancellation]`. That parameter never binds from JSON and is left out of the SMD. A `CancellationToken` parameter without the attribute is rejected at registration. A synchronous method called through `ProcessAsync` receives the token too; through `Process` and `ProcessSync` it receives the default token. When the token fires: @@ -515,6 +551,15 @@ The built-in serializer is the default. All three serializers write the envelope The full contract, what the core fixes versus what a serializer decides, is in [docs/serializers.md](docs/serializers.md). +### Limits + +```csharp +Config.SetLimits(new JsonRpcLimits(maxDocumentBytes: 8 * 1024 * 1024, maxBatchCount: 2048)); +Config.SetLimits("legacy-clients", JsonRpcLimits.Unlimited); +``` + +Zero disables either bound; `JsonRpcLimits.Unlimited` disables both. A null per-session value inherits the process-wide limits. + ### Nesting depth Every serializer exposes `MaxDepth` (default 64). A request nested deeper is answered `-32700` before any handler or binding runs, so recursive parameter conversion is bounded by the same number the JSON library itself enforces: the built-in serializer's constructor argument, `JsonSerializerOptions.MaxDepth`, or `JsonSerializerSettings.MaxDepth`. @@ -541,16 +586,14 @@ What the library does by default: - **Exception details are off.** An unhandled exception reaches the client as `-32603` with `data: null`: no type name, no message. `Config.IncludeExceptionDetails = true` sends the type, message, stack trace, source, HResult and inner exceptions; use it in development only. See [Exception disclosure](#exception-disclosure). - **Rejected values are not echoed.** A `-32602` conversion error names the parameter and the expected type, never the value sent. - **Nesting is limited to 64 levels.** A deeper request is `-32700` before any of your code runs. -- **Request size is limited on the Kestrel host only.** `MaxRequestBytes` defaults to 4 MB: HTTP answers `413`, a raw connection is aborted. The core itself does not limit document length; that is the transport's job. There is no limit on how many requests a batch holds, no response-size limit and no request deadline; a batch runs sequentially, so a 4 MB batch of small requests ties up one request's worth of server time for all of them. +- **Document and batch size are limited.** The core rejects a document over `JsonRpcLimits.MaxDocumentBytes` (4 MiB by default) or a batch with more than `MaxBatchCount` entries (1024) with `-32600` and a `data` object naming the limit, before anything is parsed or executed; `Config.SetLimits` changes them, `JsonRpcLimits.Unlimited` disables them. The Kestrel host also bounds bytes while receiving (`MaxRequestBytes`, 4 MB: HTTP answers `413`, a raw connection is aborted), so the first applicable limit wins. There is no response-size limit and no request deadline; a batch runs sequentially, so a batch of small requests ties up one request's worth of server time for all of them. - **Every `[JsonRpcMethod]` is callable.** Visibility does not matter (private methods are exposed), and `AddJsonRpcServicesFromAssembly` exposes every class in the assembly that carries the attribute. - **Requests do not create sessions.** An unknown session id answers `-32601` and leaves the registry alone; sessions are created by binding and by the per-session `Config` setters, and live until destroyed; see [Sessions and context](#sessions-and-context). - **Cancellation is cooperative.** It waits for a running method and cannot undo what the method already did. What it leaves to you: -- **Authentication and authorisation.** On HTTP, use endpoint metadata: `app.MapJsonRpc("/rpc").RequireAuthorization("api")`. A raw connection has none; listen on loopback or a Unix socket, or authenticate in front of it. -- **Per-method authorisation.** Check `Handler.RpcContext()` (the `HttpContext` on HTTP) inside the method, or reject in a pre-process handler (which moves the session to the slower path). -- **Transport security, rate limiting and deadlines.** TLS, rate limits and timeouts are Kestrel's and the middleware pipeline's, not this library's. Raw connections bypass the HTTP middleware and need equivalent controls at the listener. +Authentication, connection identity, TLS, rate limiting, request logging and deadlines belong to the host. HTTP hosts use ASP.NET Core middleware and endpoint metadata (`RequireAuthorization`, `UseRateLimiter`, the request-timeouts middleware); raw connections bypass that pipeline, so listen on loopback or a Unix socket, authenticate in front of them and use listener limits. Methods enforce authorisation that depends on parameter values. Core limits constrain admitted documents and batches, while transports bound bytes during receipt. Authentication and credential handling remain application responsibilities. - **Service state.** One service instance serves every request concurrently; see [Classes](#classes). The `jsonrpc` member policy (`Lenient` by default) is a compatibility setting, not a control; see [The `jsonrpc` member](#the-jsonrpc-member). @@ -745,10 +788,11 @@ Most 1.x services run unchanged. [Upgrading from 1.x](docs/upgrading.md) lists t ## Versioning and support - **Versioning.** The 2.x packages follow [Semantic Versioning](https://semver.org/) for the public API and the wire behaviour documented here: a breaking change to either arrives only in a new major version. +- **Deprecations.** An obsolete member warns with a `JSONRPC0xxx` diagnostic id whose link explains the replacement ([obsoletions](docs/obsoletions.md)); it stays at warning level through 2.x and is removed in the next major. - **Releases.** The four packages are built from one repository, carry one version number and are released together; use matching versions. There is no release cadence. - **Previews.** 2.0 ships as `2.0.0-preview.N` first. A preview is complete and tested, but the public API may still change between previews; the stable 2.0.0 follows once the API has settled. - **Tested** means the `net8.0` and `net10.0` test runs on Windows and Linux listed under [Requirements](#requirements). Other runtimes can load the `netstandard` assets and are not tested. -- **Trimming** is unsupported until the library is annotated and that is validated in CI. +- **Trimming and Native AOT** are unsupported until the library is annotated and that is validated in CI. - **1.x** receives no further releases. - **Changes** are recorded per version in [CHANGELOG.md](CHANGELOG.md); the NuGet release notes link there. - **Vulnerabilities** are reported privately, see [SECURITY.md](SECURITY.md). Questions and bugs go to [GitHub issues](https://github.com/Astn/JSON-RPC.NET/issues). diff --git a/SECURITY.md b/SECURITY.md index f68a8f8..9242bd6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,6 +19,6 @@ alongside it. ## What the library does and does not do -The README's [Security](README.md#security) section lists what the library does by default (exception -redaction, nesting limit, request-size limit on the Kestrel host) and what it leaves to the host: authentication, -authorisation, transport security, rate limiting and deadlines. +The README's [Security](README.md#security) section lists what the library does by default (exception redaction, nesting limit, document and batch limits in the core, request-size limit on the Kestrel host) and what it leaves to the host. + +Authentication, connection identity, TLS, rate limiting, request logging and deadlines belong to the host. HTTP hosts use ASP.NET Core middleware and endpoint metadata (`RequireAuthorization`, `UseRateLimiter`, the request-timeouts middleware); raw connections bypass that pipeline, so listen on loopback or a Unix socket, authenticate in front of them and use listener limits. Methods enforce authorisation that depends on parameter values. Core limits constrain admitted documents and batches, while transports bound bytes during receipt. Authentication and credential handling remain application responsibilities. diff --git a/docs/obsoletions.md b/docs/obsoletions.md new file mode 100644 index 0000000..340e440 --- /dev/null +++ b/docs/obsoletions.md @@ -0,0 +1,37 @@ +# Obsoletions + +These members are obsolete at warning level in 2.0.0 and stay at warning level through 2.x. They will be removed in 3.0. Follow the diagnostic link for the replacement before upgrading. To suppress one warning temporarily, use `#pragma warning disable JSONRPC0001` around the call (with the matching `#pragma warning restore JSONRPC0001`), or add `JSONRPC0001` to your project's `` property. Replace the ID for the member you use. + +## JSONRPC0001 + +`Config.SetBeforeProcessHandler` was obsoleted in 2.0.0. Use the session-specific pre-process setter: + +```csharp +Config.SetPreProcessHandler(sessionId, handler); +``` + +The obsolete alias remains at warning level through 2.x and will be removed in 3.0. + +## JSONRPC0002 + +`Handler.RegisterFuction` was obsoleted in 2.0.0. Bind the delegate through `ServiceBinder`: + +```csharp +ServiceBinder.BindMethod(sessionId, name, implementation); +``` + +Unlike `RegisterFuction`, `BindMethod` throws if the name is already registered instead of replacing it. The obsolete method remains at warning level through 2.x and will be removed in 3.0. + +## JSONRPC0003 + +`Handler.UnRegisterFunction` was obsoleted in 2.0.0. Unbind the method through `ServiceBinder`: + +```csharp +ServiceBinder.UnbindMethod(sessionId, name); +``` + +The obsolete method remains at warning level through 2.x and will be removed in 3.0. + +## Reserved ranges + +`JSONRPC0xxx` is reserved for obsoletions and `JSONRPC1xxx` for generator diagnostics. Diagnostic IDs are never reused. diff --git a/docs/upgrading.md b/docs/upgrading.md index 7bbd7d9..aa45930 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -11,9 +11,11 @@ Most 1.x services run unchanged. Read the first list before you build, and the s - **Overloads.** The default-session string overloads that take a serializer take it first: `Process(serializer, json, context)` and `ProcessSync(serializer, json, context)`. `ProcessSync(sessionId, json, context, serializer)` makes `context` required, so `ProcessSync(json, null)` still means the default session. `Process` and `ProcessAsync` do not: `Process(json, null)` no longer compiles (it is ambiguous with the `JsonRpcStateAsync` overload), and `ProcessAsync(json, null)` binds to the session overload with `json` as the session id and a null document, which throws `ArgumentNullException`. Write `Process(json)`, `Process(json, context: null)` or `ProcessAsync(json, context: null)`. - **DTOs.** `JsonRequest`, `JsonResponse` and `JsonRpcException` are plain DTOs without Json.NET attributes. `JsonRequest.Params` is the active serializer's object model, so cast to `JObject`/`JArray` only when the Json.NET serializer is active. - **SMD.** `SMD.Services` is an `SMDServiceCollection` (an `IDictionary`) instead of a `Dictionary`, and its setter is gone. Every mutation through it updates the dispatch table at once, so a removed method is unreachable immediately. `SMD.Types` is now `Dictionary>` and a process-wide registry (it was reset whenever a session was created). +- **Reserved names.** Names beginning with `rpc.` and the name `$/cancelRequest` are refused at registration on every path (`BindInterface` refused `rpc.` alone before). ## Changes clients will see on the wire +- **Limits.** A document over 4 MiB or a batch with more than 1024 entries is `-32600` with `data = {"limit":…,"maximum":…}` before anything runs; `Config.SetLimits(JsonRpcLimits.Unlimited)` restores the 1.x behaviour. - **Version member.** The `jsonrpc` member is checked (`Config.VersionPolicy`, default `Lenient`): a missing member is still accepted, but `"jsonrpc":"1.0"` or a non-string value is now `-32600`. Set `Ignore` for the 1.x behaviour. - **Parse errors.** Requests nested deeper than 64 levels are `-32700` (configurable per serializer, see [Nesting depth](../README.md#nesting-depth)). Invalid UTF-8 and non-strict JSON (unless the serializer is lenient) are `-32700` as well. - **Batches.** The empty-batch error code is the spec's `-32600` (it was `3200`). Batches made only of notifications produce an empty response instead of `[]` with a dangling comma. A batch always answers with a JSON array when it produces at least one response; a one-request batch is no longer unwrapped to a bare response object. diff --git a/site/build.py b/site/build.py index 28a5e88..5aaa0ce 100644 --- a/site/build.py +++ b/site/build.py @@ -49,6 +49,8 @@ "Release notes for every version: what 2.0 adds, changes, removes and fixes, and the 1.x history."), ("upgrading.html", "docs/upgrading.md", "Upgrading from 1.x", "Upgrading from 1.x", "Guide", "What a 1.x server must change to build against 2.0, what clients will see on the wire, and what behaves differently inside the server."), + ("obsoletions.html", "docs/obsoletions.md", "Obsoletions", "Obsoletions", "Guide", + "Warning-level obsolete members, their replacements, diagnostic IDs and planned removal in 3.0."), ("serializers.html", "docs/serializers.md", "Serializers", "Serializers", "Guide", "How the built-in, Json.NET and System.Text.Json serializers differ, and how to configure or write one."), ("aspnetcore.html", "AustinHarris.JsonRpc.AspNetCore/README.md", "ASP.NET Core hosting", "ASP.NET Core hosting", "Packages", @@ -75,8 +77,11 @@ def slug(text: str) -> str: - """GitHub's heading anchor: lowercase, drop punctuation, spaces to hyphens.""" - text = html.unescape(TAGS.sub("", text)).strip().lower() + """GitHub's heading anchor, preserving exact diagnostic IDs for their URLs.""" + text = html.unescape(TAGS.sub("", text)).strip() + if re.fullmatch(r"JSONRPC\d{4}", text): + return text + text = text.lower() text = re.sub(r"[^\w\- ]", "", text) return text.replace(" ", "-")