using System; using System.Globalization; using System.IO; using System.Text; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace XFE.SeAgent.Plugin.Protocol { public sealed class AgentRequest { public JToken Id { get; } public string Method { get; } public JObject Parameters { get; } public int TimeoutMs { get; } internal AgentRequest(JToken id, string method, JObject parameters, int timeoutMs) { Id = id; Method = method; Parameters = parameters; TimeoutMs = timeoutMs; } } /// A dispatcher can use this exception for a bounded, public RPC error. public class AgentRpcException : Exception { public string Code { get; } public AgentRpcException(string code, string message) : base(message) { Code = code; } } internal sealed class AgentProtocolException : AgentRpcException { internal JToken RequestId { get; } internal AgentProtocolException(string code, string message, JToken requestId = null) : base(code, message) { RequestId = requestId; } } public static class AgentProtocol { public const int MaxRequestBytes = 1024 * 1024; public const int MaxResponseBytes = 4 * 1024 * 1024; public const int DefaultTimeoutMs = 10000; public const int MaximumTimeoutMs = 30000; internal static readonly Encoding Utf8 = new UTF8Encoding(false, true); public static AgentRequest ParseRequest(string text) { if (text == null) throw new ArgumentNullException(nameof(text)); if (Utf8.GetByteCount(text) > MaxRequestBytes) throw new AgentProtocolException("request_too_large", "Request exceeds the 1 MiB limit."); JObject body; try { using (var reader = new JsonTextReader(new StringReader(text))) { reader.DateParseHandling = DateParseHandling.None; reader.FloatParseHandling = FloatParseHandling.Decimal; reader.Culture = CultureInfo.InvariantCulture; reader.MaxDepth = 32; body = JObject.Load(reader, new JsonLoadSettings { DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error }); if (reader.Read()) throw new JsonReaderException("Only one JSON object is allowed."); } } catch (JsonException) { throw new AgentProtocolException("parse_error", "Request must be one JSON object with a maximum depth of 32."); } JToken id = body["id"]; if (id == null || (id.Type != JTokenType.String && id.Type != JTokenType.Integer) || (id.Type == JTokenType.String ? ((string)id).Length : id.ToString(Formatting.None).Length) > 128) throw new AgentProtocolException("invalid_request", "id must be a string or integer of at most 128 characters."); id = id.DeepClone(); JToken methodValue = body["method"]; string method = methodValue != null && methodValue.Type == JTokenType.String ? (string)methodValue : null; if (string.IsNullOrEmpty(method) || method.Length > 128 || !IsMethodName(method)) throw new AgentProtocolException("invalid_request", "method must contain 1–128 ASCII letters, digits, dots, dashes or underscores.", id); JToken arguments = body["params"]; if (arguments != null && arguments.Type != JTokenType.Null && !(arguments is JObject)) throw new AgentProtocolException("invalid_params", "params must be an object.", id); int timeoutMs = DefaultTimeoutMs; JToken timeout = body["timeoutMs"]; if (timeout != null) { if (timeout.Type != JTokenType.Integer || !int.TryParse(timeout.ToString(Formatting.None), NumberStyles.None, CultureInfo.InvariantCulture, out timeoutMs) || timeoutMs < 1 || timeoutMs > MaximumTimeoutMs) throw new AgentProtocolException("invalid_request", "timeoutMs must be an integer from 1 to 30000.", id); } return new AgentRequest(id, method, arguments as JObject ?? new JObject(), timeoutMs); } private static bool IsMethodName(string method) { foreach (char value in method) if (!(value >= 'a' && value <= 'z') && !(value >= 'A' && value <= 'Z') && !(value >= '0' && value <= '9') && value != '.' && value != '-' && value != '_') return false; return true; } public static JObject Success(JToken id, JObject result) { return new JObject { ["id"] = id?.DeepClone() ?? JValue.CreateNull(), ["result"] = result?.DeepClone() ?? (JToken)JValue.CreateNull() }; } public static JObject Error(JToken id, string code, string message) { return new JObject { ["id"] = id?.DeepClone() ?? JValue.CreateNull(), ["error"] = new JObject { ["code"] = Limit(code, 64), ["message"] = Limit(message, 2048) } }; } private static string Limit(string value, int length) { return string.IsNullOrEmpty(value) ? "Unknown error." : value.Length <= length ? value : value.Substring(0, length); } /// Returns UTF-8 JSON plus LF. Serialization itself is bounded, not only the final allocation. public static byte[] SerializeResponse(JObject response) { if (response == null) throw new ArgumentNullException(nameof(response)); using (var output = new LimitedMemoryStream(MaxResponseBytes)) { using (var writer = new StreamWriter(output, Utf8, 4096, true)) using (var json = new JsonTextWriter(writer) { Formatting = Formatting.None, CloseOutput = false }) { response.WriteTo(json); json.Flush(); writer.Flush(); } output.WriteByte((byte)'\n'); return output.ToArray(); } } private sealed class LimitedMemoryStream : MemoryStream { private readonly int limit; internal LimitedMemoryStream(int limit) : base(4096) { this.limit = limit; } public override void Write(byte[] buffer, int offset, int count) { if (Position + count > limit) throw new AgentRpcException("response_too_large", "Response exceeds the 4 MiB limit. Request fewer items."); base.Write(buffer, offset, count); } public override void WriteByte(byte value) { if (Position >= limit) throw new AgentRpcException("response_too_large", "Response exceeds the 4 MiB limit. Request fewer items."); base.WriteByte(value); } } } }