From 16da8b50e34170eda2e4b853d535aeb2a0b3c79b Mon Sep 17 00:00:00 2001 From: waynercheung Date: Tue, 4 Aug 2026 17:55:05 +0800 Subject: [PATCH] fix(jsonrpc): normalize error responses and fatal handling Replace jsonrpc4j's unhandled -32001 fallback with a fixed -32603 response so unmapped exception types and raw messages no longer reach clients. Keep the documented -32001 contract for net_version and eth_chainId through explicit mappings with fixed message and data. Propagate VirtualMachineError, ThreadDeath, LinkageError, and TronError through the JSON-RPC boundary. Scan complete cause chains without allocating a visited set, detecting cycles without missing fatal causes. Classify Throwables escaping dispatch before logging or mapping them. Before rethrowing an Error, unwrap response decorators with a bounded walk and make a best-effort attempt to commit an empty HTTP 500 on the underlying response. Preserve the original error if cleanup fails, and abandon cleanup without delegating into cyclic or unresolved wrappers. Route single requests through handleRequest because the servlet handle API catches Throwable. Catch RuntimeException, IOException, and Error at both dispatch boundaries, then propagate a classified fatal cause or return -32603 when a response is appropriate. This also covers non-fatal Errors from interceptor hooks. Discard partial dispatch output on failure. Recover each failed batch element without discarding earlier results or skipping later requests. Share internal-error construction across serialization, dispatch, and response-parsing failures. Count only the replacement when parsing fails, retaining existing overflow rules and the current single/batch handling of requests without an id. Bound unmapped-exception logging by RPC method and exception type, and log chain-identity failures on state transitions. Log the first escaped dispatch failure in each batch at ERROR with its cause; repeats use DEBUG with only the index and exception type. Restore interrupted status for asynchronous log queries. Reject Boolean, object, and array request IDs before dispatch with -32600 and id:null. Reject non-null scalar params with -32600, preserve valid IDs, and isolate invalid batch elements so their siblings run. Missing, null, array, and object params keep their existing semantics. Remove the obsolete request replay wrapper and HTTP status provider. Add resolver, servlet, embedded-Jetty, chain-identity, asynchronous failure, dispatch-contract, and request-envelope regression coverage. Exercise the real HTTP interceptor chain and observe commitment without performing it on the servlet's behalf. Test interceptor Errors with a real server and preserve the existing notification response-suppression rules with characterization tests. Add a package-private server injection seam for servlet tests without changing production initialization. --- .../filter/CachedBodyRequestWrapper.java | 97 -- .../jsonrpc/JsonRpcErrorResolver.java | 116 +- .../core/services/jsonrpc/JsonRpcServlet.java | 225 +++- .../core/services/jsonrpc/TronJsonRpc.java | 20 +- .../services/jsonrpc/TronJsonRpcImpl.java | 18 +- .../jsonrpc/filters/LogBlockQuery.java | 12 +- .../filter/CachedBodyRequestWrapperTest.java | 109 -- .../jsonrpc/JsonRpcDispatchContractTest.java | 209 ++++ .../jsonrpc/JsonRpcErrorResolverTest.java | 299 ++++- ...onRpcErrorSanitizationIntegrationTest.java | 729 ++++++++++++ .../jsonrpc/JsonRpcServletJettyTest.java | 300 +++++ .../services/jsonrpc/JsonRpcServletTest.java | 1052 ++++++++++++++++- .../TronJsonRpcImplChainIdentityTest.java | 78 ++ .../filters/LogBlockQueryFailureTest.java | 108 ++ 14 files changed, 3052 insertions(+), 320 deletions(-) delete mode 100644 framework/src/main/java/org/tron/core/services/filter/CachedBodyRequestWrapper.java delete mode 100644 framework/src/test/java/org/tron/core/services/filter/CachedBodyRequestWrapperTest.java create mode 100644 framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcDispatchContractTest.java create mode 100644 framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcErrorSanitizationIntegrationTest.java create mode 100644 framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletJettyTest.java create mode 100644 framework/src/test/java/org/tron/core/services/jsonrpc/TronJsonRpcImplChainIdentityTest.java create mode 100644 framework/src/test/java/org/tron/core/services/jsonrpc/filters/LogBlockQueryFailureTest.java diff --git a/framework/src/main/java/org/tron/core/services/filter/CachedBodyRequestWrapper.java b/framework/src/main/java/org/tron/core/services/filter/CachedBodyRequestWrapper.java deleted file mode 100644 index 683fe849f71..00000000000 --- a/framework/src/main/java/org/tron/core/services/filter/CachedBodyRequestWrapper.java +++ /dev/null @@ -1,97 +0,0 @@ -package org.tron.core.services.filter; - -import java.io.BufferedReader; -import java.io.ByteArrayInputStream; -import java.io.InputStreamReader; -import java.nio.charset.Charset; -import java.nio.charset.IllegalCharsetNameException; -import java.nio.charset.StandardCharsets; -import java.nio.charset.UnsupportedCharsetException; -import javax.servlet.ReadListener; -import javax.servlet.ServletInputStream; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletRequestWrapper; - -/** - * Wraps a request to replay a pre-read body from a byte array, - * allowing the body to be read more than once. - * - *

Scope: designed for synchronous, raw-body POST endpoints - * (e.g. JSON-RPC). It is NOT compatible with: - *

- * - *

Multiple calls to {@code getInputStream()} (or {@code getReader()}) - * are allowed and each returns a fresh stream over the same cached body — - * a deliberate extension of the standard servlet contract. - */ -public class CachedBodyRequestWrapper extends HttpServletRequestWrapper { - - private enum BodyAccessor { NONE, STREAM, READER } - - private final byte[] body; - private BodyAccessor accessor = BodyAccessor.NONE; - - public CachedBodyRequestWrapper(HttpServletRequest request, byte[] body) { - super(request); - this.body = body; - } - - @Override - public ServletInputStream getInputStream() { - if (accessor == BodyAccessor.READER) { - throw new IllegalStateException("getReader() has already been called on this request"); - } - accessor = BodyAccessor.STREAM; - final ByteArrayInputStream bais = new ByteArrayInputStream(body); - return new ServletInputStream() { - @Override - public int read() { - return bais.read(); - } - - @Override - public int read(byte[] b, int off, int len) { - return bais.read(b, off, len); - } - - @Override - public boolean isFinished() { - return bais.available() == 0; - } - - @Override - public boolean isReady() { - return true; - } - - @Override - public void setReadListener(ReadListener readListener) { - throw new UnsupportedOperationException( - "async I/O is not supported on cached body"); - } - }; - } - - @Override - public BufferedReader getReader() { - if (accessor == BodyAccessor.STREAM) { - throw new IllegalStateException("getInputStream() has already been called on this request"); - } - accessor = BodyAccessor.READER; - String encoding = getCharacterEncoding(); - Charset charset; - try { - charset = encoding != null ? Charset.forName(encoding) : StandardCharsets.UTF_8; - } catch (IllegalCharsetNameException | UnsupportedCharsetException ex) { - charset = StandardCharsets.UTF_8; - } - return new BufferedReader(new InputStreamReader(new ByteArrayInputStream(body), charset)); - } -} diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolver.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolver.java index b92b3cf1af6..b915de149cf 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolver.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolver.java @@ -1,50 +1,140 @@ package org.tron.core.services.jsonrpc; import com.fasterxml.jackson.databind.JsonNode; -import com.googlecode.jsonrpc4j.ErrorData; import com.googlecode.jsonrpc4j.ErrorResolver; import com.googlecode.jsonrpc4j.JsonRpcError; import com.googlecode.jsonrpc4j.JsonRpcErrors; +import com.googlecode.jsonrpc4j.ProxyUtil; import com.googlecode.jsonrpc4j.ReflectionUtil; import java.lang.reflect.Method; import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.tron.core.exception.TronError; import org.tron.core.exception.jsonrpc.JsonRpcException; /** * {@link ErrorResolver} that uses annotations. */ +@Slf4j(topic = "API") public enum JsonRpcErrorResolver implements ErrorResolver { INSTANCE; + private static final Set SEEN_FAILURES = ConcurrentHashMap.newKeySet(); + /** * {@inheritDoc} */ @Override public JsonError resolveError( Throwable thrownException, Method method, List arguments) { - JsonRpcError resolver = getResolverForException(thrownException, method); - if (notFoundResolver(resolver)) { - return null; + Error fatal = findFatalCause(thrownException); + if (fatal != null) { + throw fatal; + } + + JsonRpcError resolver = method == null + ? null : getResolverForException(thrownException, method); + if (resolver == null) { + logUnhandledException(method, thrownException); + return new JsonError(JsonError.INTERNAL_ERROR.code, "Internal error", null); } String message = hasErrorMessage(resolver) ? resolver.message() : thrownException.getMessage(); + if (StringUtils.isBlank(message)) { + message = defaultMessageFor(resolver.code()); + } - // data priority: exception > annotation > default ErrorData + // data priority: exception > annotation Object data = null; if (thrownException instanceof JsonRpcException) { JsonRpcException jsonRpcException = (JsonRpcException) thrownException; data = jsonRpcException.getData(); } - if (data == null) { - data = hasErrorData(resolver) - ? resolver.data() - : new ErrorData(resolver.exception().getName(), message); + if (data == null && hasErrorData(resolver)) { + data = resolver.data(); } - + return new JsonError(resolver.code(), message, data); } + static Error findFatalCause(Throwable throwable) { + // Avoid allocations when handling memory exhaustion, without truncating deep cause chains. + // Check both fast-pointer steps so cycle detection cannot skip a fatal cause inside a cycle. + Throwable slow = throwable; + Throwable fast = throwable; + while (fast != null) { + if (isFatal(fast)) { + return (Error) fast; + } + fast = fast.getCause(); + if (fast == null) { + return null; + } + if (isFatal(fast)) { + return (Error) fast; + } + fast = fast.getCause(); + slow = slow.getCause(); + if (fast == slow) { + return null; + } + } + return null; + } + + private static boolean isFatal(Throwable cause) { + return cause instanceof VirtualMachineError + || cause instanceof ThreadDeath + || cause instanceof LinkageError + || cause instanceof TronError; + } + + private static void logUnhandledException(Method method, Throwable thrownException) { + String methodName = rpcMethodName(method); + String exceptionName = thrownException.getClass().getName(); + String key = methodName + '\0' + exceptionName; + if (SEEN_FAILURES.add(key)) { + // The first occurrence retains the Throwable for diagnosis. Repeated failures omit both + // the stack and exception message so a request loop cannot flood the WARN log. + logger.warn("Unhandled exception in JSON-RPC method {}", methodName, thrownException); + } else { + logger.debug("Repeated unhandled exception in JSON-RPC method {} ({})", + methodName, exceptionName); + } + } + + static void clearSeenFailuresForTest() { + SEEN_FAILURES.clear(); + } + + private static String rpcMethodName(Method method) { + if (method == null) { + return "unknown"; + } + try { + return ProxyUtil.getMethodName(method); + } catch (RuntimeException e) { + return method.getName(); + } + } + + private static String defaultMessageFor(int code) { + switch (code) { + case -32600: + return "Invalid Request"; + case -32601: + return "Method not found"; + case -32602: + return "Invalid params"; + default: + return "Internal error"; + } + } + private JsonRpcError getResolverForException(Throwable thrownException, Method method) { JsonRpcErrors errors = ReflectionUtil.getAnnotation(method, JsonRpcErrors.class); if (hasAnnotations(errors)) { @@ -57,10 +147,6 @@ private JsonRpcError getResolverForException(Throwable thrownException, Method m return null; } - private boolean notFoundResolver(JsonRpcError resolver) { - return resolver == null; - } - private boolean hasErrorMessage(JsonRpcError em) { // noinspection ConstantConditions return em.message() != null && !em.message().trim().isEmpty(); @@ -78,4 +164,4 @@ private boolean hasAnnotations(JsonRpcErrors errors) { private boolean isExceptionInstanceOfError(Throwable target, JsonRpcError em) { return em.exception().isInstance(target); } -} \ No newline at end of file +} diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java index ca249da4e5d..4f642e7b391 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java @@ -8,7 +8,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.googlecode.jsonrpc4j.HttpStatusCodeProvider; import com.googlecode.jsonrpc4j.JsonRpcInterceptor; import com.googlecode.jsonrpc4j.JsonRpcServer; import com.googlecode.jsonrpc4j.ProxyUtil; @@ -19,6 +18,8 @@ import java.util.Collections; import javax.servlet.ServletConfig; import javax.servlet.ServletException; +import javax.servlet.ServletResponse; +import javax.servlet.ServletResponseWrapper; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; @@ -27,7 +28,6 @@ import org.tron.common.parameter.CommonParameter; import org.tron.core.Constant; import org.tron.core.services.filter.BufferedResponseWrapper; -import org.tron.core.services.filter.CachedBodyRequestWrapper; import org.tron.core.services.http.RateLimiterServlet; @Component @@ -35,6 +35,7 @@ public class JsonRpcServlet extends RateLimiterServlet { private static final ObjectMapper MAPPER = buildMapper(); + private static final int MAX_RESPONSE_WRAPPER_DEPTH = 16; private static ObjectMapper buildMapper() { JsonFactory factory = JsonFactory.builder() @@ -68,6 +69,10 @@ private enum JsonRpcError { @Autowired private JsonRpcInterceptor interceptor; + void setRpcServer(JsonRpcServer rpcServer) { + this.rpcServer = rpcServer; + } + @Override public void init(ServletConfig config) throws ServletException { super.init(config); @@ -82,19 +87,6 @@ public void init(ServletConfig config) throws ServletException { rpcServer = new JsonRpcServer(compositeService); rpcServer.setErrorResolver(JsonRpcErrorResolver.INSTANCE); - HttpStatusCodeProvider httpStatusCodeProvider = new HttpStatusCodeProvider() { - @Override - public int getHttpStatusCode(int resultCode) { - return 200; - } - - @Override - public Integer getJsonRpcCode(int httpStatusCode) { - return null; - } - }; - rpcServer.setHttpStatusCodeProvider(httpStatusCodeProvider); - rpcServer.setShouldLogInvocationErrors(false); if (CommonParameter.getInstance().isMetricsPrometheusEnable()) { rpcServer.setInterceptorList(Collections.singletonList(interceptor)); @@ -103,6 +95,15 @@ public Integer getJsonRpcCode(int httpStatusCode) { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { + try { + doPostInternal(req, resp); + } catch (Error fatal) { + commitBareInternalServerError(resp); + throw fatal; + } + } + + private void doPostInternal(HttpServletRequest req, HttpServletResponse resp) throws IOException { CommonParameter parameter = CommonParameter.getInstance(); // Transport IOException from readBody propagates as HTTP 500 (genuine IO failure). @@ -133,6 +134,15 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I writeJsonRpcError(resp, JsonRpcError.INVALID_REQUEST, "Invalid Request", null, false); return; } + if (!isBatch && hasInvalidRequestId(rootNode)) { + writeJsonRpcError(resp, JsonRpcError.INVALID_REQUEST, "Invalid Request", null, false); + return; + } + if (!isBatch && hasScalarParams(rootNode)) { + writeJsonRpcError(resp, JsonRpcError.INVALID_REQUEST, "Invalid Request", + rootNode.get("id"), false); + return; + } int batchSize = parameter.getJsonRpcMaxBatchSize(); if (isBatch && batchSize > 0 && rootNode.size() > batchSize) { writeJsonRpcError(resp, JsonRpcError.EXCEED_LIMIT, @@ -144,25 +154,35 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I if (isBatch) { handleBatch(resp, rootNode, maxResponseSize); } else { - handleSingle(req, resp, rootNode, body, maxResponseSize); + handleSingle(resp, rootNode, body, maxResponseSize); } } - private void handleSingle(HttpServletRequest req, HttpServletResponse resp, - JsonNode rootNode, byte[] body, int maxResponseSize) throws IOException { - CachedBodyRequestWrapper cachedReq = new CachedBodyRequestWrapper(req, body); + private void handleSingle(HttpServletResponse resp, JsonNode rootNode, byte[] body, + int maxResponseSize) throws IOException { BufferedResponseWrapper bufferedResp = new BufferedResponseWrapper( resp, maxResponseSize); try { - rpcServer.handle(cachedReq, bufferedResp); - } catch (RuntimeException e) { + // JsonRpcServer.handle catches Throwable and would swallow fatal errors rethrown by the + // resolver. Use the lower-level entry point so single and batch requests share a boundary. + rpcServer.handleRequest(new ByteArrayInputStream(body), bufferedResp.getOutputStream()); + } catch (RuntimeException | IOException | Error e) { + rethrowIfFatal(e); logger.error("RPC execution failed", e); + if (!rootNode.has("id")) { + resp.setContentType("application/json-rpc"); + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentLength(0); + return; + } writeJsonRpcError(resp, JsonRpcError.INTERNAL_ERROR, "Internal error", rootNode.get("id"), false); return; } + bufferedResp.setContentType("application/json-rpc"); + bufferedResp.setStatus(HttpServletResponse.SC_OK); bufferedResp.commitToResponse(); if (bufferedResp.isOverflow()) { writeJsonRpcError(resp, JsonRpcError.RESPONSE_TOO_LARGE, @@ -177,14 +197,30 @@ private void handleBatch(HttpServletResponse resp, JsonNode rootNode, int maxRes ArrayNode batchResult = MAPPER.createArrayNode(); int accumulatedSize = 2; // "[]" boolean overflow = false; + BatchFailureLog failureLog = new BatchFailureLog(); for (int i = 0; i < rootNode.size(); i++) { JsonNode subRequest = rootNode.get(i); + if (!subRequest.isObject() || hasInvalidRequestId(subRequest) + || hasScalarParams(subRequest)) { + ObjectNode errNode = buildErrorNode(JsonRpcError.INVALID_REQUEST, "Invalid Request", + subRequest.get("id")); + if (!overflow) { + byte[] errBytes = MAPPER.writeValueAsBytes(errNode); + int addition = errBytes.length + (!batchResult.isEmpty() ? 1 : 0); + if (maxResponseSize > 0 && accumulatedSize + addition > maxResponseSize) { + overflow = true; + } else { + accumulatedSize += addition; + } + } + batchResult.add(errNode); + continue; + } + if (overflow) { - if (!subRequest.isObject()) { - batchResult.add(buildErrorNode(JsonRpcError.INVALID_REQUEST, "Invalid Request", null)); - } else if (subRequest.has("id")) { + if (subRequest.has("id")) { // Notifications (no "id") do not get a response even on overflow. batchResult.add(buildErrorNode(JsonRpcError.RESPONSE_TOO_LARGE, "Response exceeds the limit of " + maxResponseSize + " bytes", @@ -193,43 +229,14 @@ private void handleBatch(HttpServletResponse resp, JsonNode rootNode, int maxRes continue; } - if (!subRequest.isObject()) { - ObjectNode errNode = buildErrorNode(JsonRpcError.INVALID_REQUEST, "Invalid Request", null); - byte[] errBytes = MAPPER.writeValueAsBytes(errNode); - int addition = errBytes.length + (!batchResult.isEmpty() ? 1 : 0); - if (maxResponseSize > 0 && accumulatedSize + addition > maxResponseSize) { - overflow = true; - } else { - accumulatedSize += addition; - } - batchResult.add(errNode); - continue; - } - - byte[] subBody; - try { - subBody = MAPPER.writeValueAsBytes(subRequest); - } catch (JsonProcessingException e) { - writeJsonRpcError(resp, JsonRpcError.INTERNAL_ERROR, "Internal error", null, true); - return; - } - - ByteArrayOutputStream subOutput = new ByteArrayOutputStream(); - try { - rpcServer.handleRequest(new ByteArrayInputStream(subBody), subOutput); - } catch (RuntimeException e) { - logger.error("RPC execution failed for batch sub-request {}", i, e); - writeJsonRpcError(resp, JsonRpcError.INTERNAL_ERROR, "Internal error", null, true); - return; - } - - byte[] responseBytes = subOutput.toByteArray(); + byte[] responseBytes = executeBatchRequest(subRequest, i, failureLog); if (responseBytes.length == 0) { continue; // notification — no response } // comma(,) separator between array elements int addition = responseBytes.length + (!batchResult.isEmpty() ? 1 : 0); + // Reject bytes beyond the remaining budget before parsing, even if they are malformed. if (maxResponseSize > 0 && accumulatedSize + addition > maxResponseSize) { overflow = true; batchResult.add(buildErrorNode(JsonRpcError.RESPONSE_TOO_LARGE, @@ -237,15 +244,24 @@ private void handleBatch(HttpServletResponse resp, JsonNode rootNode, int maxRes subRequest.get("id"))); continue; } - accumulatedSize += addition; - JsonNode responseNode; try { responseNode = MAPPER.readTree(responseBytes); } catch (IOException e) { - writeJsonRpcError(resp, JsonRpcError.INTERNAL_ERROR, "Internal error", null, true); - return; + responseBytes = internalErrorResponse(subRequest.get("id")); + responseNode = MAPPER.readTree(responseBytes); + // Charge only the replacement, not the discarded malformed response bytes. + addition = responseBytes.length + (!batchResult.isEmpty() ? 1 : 0); + // A replacement error can be larger than the malformed response it replaces. + if (maxResponseSize > 0 && accumulatedSize + addition > maxResponseSize) { + overflow = true; + batchResult.add(buildErrorNode(JsonRpcError.RESPONSE_TOO_LARGE, + "Response exceeds the limit of " + maxResponseSize + " bytes", + subRequest.get("id"))); + continue; + } } + accumulatedSize += addition; batchResult.add(responseNode); } @@ -265,6 +281,82 @@ private void handleBatch(HttpServletResponse resp, JsonNode rootNode, int maxRes resp.getOutputStream().flush(); } + private byte[] executeBatchRequest(JsonNode subRequest, int index, BatchFailureLog failureLog) + throws IOException { + byte[] subBody; + try { + subBody = MAPPER.writeValueAsBytes(subRequest); + } catch (JsonProcessingException e) { + return internalErrorResponse(subRequest.get("id")); + } + + ByteArrayOutputStream subOutput = new ByteArrayOutputStream(); + try { + rpcServer.handleRequest(new ByteArrayInputStream(subBody), subOutput); + } catch (RuntimeException | IOException | Error e) { + rethrowIfFatal(e); + failureLog.record(index, e); + return internalErrorResponse(subRequest.get("id")); + } + return subOutput.toByteArray(); + } + + private static class BatchFailureLog { + private boolean logged; + + private void record(int index, Throwable failure) { + if (!logged) { + logged = true; + logger.error("RPC execution failed for batch sub-request {}", index, failure); + } else { + logger.debug("RPC execution failed for batch sub-request {} ({})", + index, failure.getClass().getName()); + } + } + } + + private byte[] internalErrorResponse(JsonNode id) throws JsonProcessingException { + return MAPPER.writeValueAsBytes( + buildErrorNode(JsonRpcError.INTERNAL_ERROR, "Internal error", id)); + } + + private static void rethrowIfFatal(Throwable exception) { + Error fatal = JsonRpcErrorResolver.findFatalCause(exception); + if (fatal != null) { + throw fatal; + } + } + + private static void commitBareInternalServerError(HttpServletResponse resp) { + try { + // Response decorators such as CharResponseWrapper do not propagate flushBuffer. + // Bound this allocation-free walk and never delegate cleanup through an unresolved wrapper. + HttpServletResponse target = resp; + int depth = 0; + while (target instanceof ServletResponseWrapper) { + if (depth++ >= MAX_RESPONSE_WRAPPER_DEPTH) { + return; + } + ServletResponse inner = ((ServletResponseWrapper) target).getResponse(); + if (inner == target || !(inner instanceof HttpServletResponse)) { + return; + } + target = (HttpServletResponse) inner; + } + if (target.isCommitted()) { + return; + } + // Best effort: commit an empty response before rethrowing the Error so the container does + // not render its default error page, which may expose Throwable details. + target.resetBuffer(); + target.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + target.setContentLength(0); + target.flushBuffer(); + } catch (Throwable ignored) { + // Cleanup must never replace the original fatal Error. + } + } + private byte[] readBody(InputStream in) throws IOException { ByteArrayOutputStream buffer = new ByteArrayOutputStream(); byte[] tmp = new byte[4096]; @@ -281,7 +373,7 @@ private ObjectNode buildErrorNode(JsonRpcError error, String message, JsonNode i ObjectNode errNode = errorObj.putObject("error"); errNode.put("code", error.code); errNode.put("message", message); - if (id != null && !id.isNull() && !id.isMissingNode()) { + if (isValidRequestId(id) && !id.isNull()) { errorObj.set("id", id); } else { errorObj.putNull("id"); @@ -289,6 +381,19 @@ private ObjectNode buildErrorNode(JsonRpcError error, String message, JsonNode i return errorObj; } + private static boolean hasInvalidRequestId(JsonNode request) { + return request.has("id") && !isValidRequestId(request.get("id")); + } + + private static boolean hasScalarParams(JsonNode request) { + // Explicit null keeps the existing jsonrpc4j dispatch semantics. + return request.hasNonNull("params") && !request.get("params").isContainerNode(); + } + + private static boolean isValidRequestId(JsonNode id) { + return id != null && (id.isNull() || id.isTextual() || id.isNumber()); + } + private void writeJsonRpcError(HttpServletResponse resp, JsonRpcError error, String message, JsonNode id, boolean isBatch) throws IOException { ObjectNode errorObj = buildErrorNode(error, message, id); diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpc.java b/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpc.java index 50da763b8b9..0ca897c0eb7 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpc.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpc.java @@ -73,9 +73,17 @@ BlockResult ethGetBlockByNumber(String bnOrId, Boolean fullTransactionObjects) throws JsonRpcInvalidParamsException; @JsonRpcMethod("net_version") + @JsonRpcErrors({ + @JsonRpcError(exception = JsonRpcInternalException.class, code = -32001, + message = "Chain identity unavailable", data = "{}"), + }) String getNetVersion() throws JsonRpcInternalException; @JsonRpcMethod("eth_chainId") + @JsonRpcErrors({ + @JsonRpcError(exception = JsonRpcInternalException.class, code = -32001, + message = "Chain identity unavailable", data = "{}"), + }) String ethChainId() throws JsonRpcInternalException; @JsonRpcMethod("net_listening") @@ -328,8 +336,10 @@ Object[] getFilterChanges(String filterId) @JsonRpcError(exception = JsonRpcMethodNotFoundException.class, code = -32601, data = "{}"), @JsonRpcError(exception = JsonRpcTooManyResultException.class, code = -32005, data = "{}"), @JsonRpcError(exception = BadItemException.class, code = -32000, data = "{}"), - @JsonRpcError(exception = ExecutionException.class, code = -32000, data = "{}"), - @JsonRpcError(exception = InterruptedException.class, code = -32000, data = "{}"), + @JsonRpcError(exception = ExecutionException.class, code = -32000, + message = "Internal error", data = "{}"), + @JsonRpcError(exception = InterruptedException.class, code = -32000, + message = "Internal error", data = "{}"), @JsonRpcError(exception = ItemNotFoundException.class, code = -32000, data = "{}"), }) LogFilterElement[] getLogs(FilterRequest fr) throws JsonRpcInvalidParamsException, @@ -342,8 +352,10 @@ LogFilterElement[] getLogs(FilterRequest fr) throws JsonRpcInvalidParamsExceptio @JsonRpcError(exception = JsonRpcMethodNotFoundException.class, code = -32601, data = "{}"), @JsonRpcError(exception = JsonRpcTooManyResultException.class, code = -32005, data = "{}"), @JsonRpcError(exception = BadItemException.class, code = -32000, data = "{}"), - @JsonRpcError(exception = ExecutionException.class, code = -32000, data = "{}"), - @JsonRpcError(exception = InterruptedException.class, code = -32000, data = "{}"), + @JsonRpcError(exception = ExecutionException.class, code = -32000, + message = "Internal error", data = "{}"), + @JsonRpcError(exception = InterruptedException.class, code = -32000, + message = "Internal error", data = "{}"), @JsonRpcError(exception = ItemNotFoundException.class, code = -32000, data = "{}"), }) LogFilterElement[] getFilterLogs(String filterId) throws JsonRpcInvalidParamsException, diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpcImpl.java b/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpcImpl.java index 6be47886117..4086507c0fb 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpcImpl.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpcImpl.java @@ -36,6 +36,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.regex.Pattern; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -193,6 +194,7 @@ public enum RequestSource { private final ExecutorService sectionExecutor; private final NodeInfoService nodeInfoService; private final Wallet wallet; + private final AtomicBoolean chainIdentityLookupFailed = new AtomicBoolean(); @Autowired private Manager manager; private final String esName = "query-section"; @@ -424,9 +426,21 @@ public String ethChainId() throws JsonRpcInternalException { // return hash of genesis block try { byte[] chainId = wallet.getBlockCapsuleByNum(0).getBlockId().getBytes(); - return ByteArray.toJsonHex(Arrays.copyOfRange(chainId, chainId.length - 4, chainId.length)); + String result = ByteArray.toJsonHex( + Arrays.copyOfRange(chainId, chainId.length - 4, chainId.length)); + if (chainIdentityLookupFailed.compareAndSet(true, false)) { + logger.info("Chain identity lookup recovered"); + } + return result; } catch (Exception e) { - throw new JsonRpcInternalException(e.getMessage()); + // Mapped errors bypass the resolver's unhandled-exception log, so record the complete + // cause once per failure episode at the lookup boundary. + if (chainIdentityLookupFailed.compareAndSet(false, true)) { + logger.warn("Chain identity lookup failed", e); + } + // Keep the cause so the resolver can still find a fatal error wrapped in an exception, + // and carry the public message on the exception itself rather than the underlying one. + throw new JsonRpcInternalException("Chain identity unavailable", e); } } diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/filters/LogBlockQuery.java b/framework/src/main/java/org/tron/core/services/jsonrpc/filters/LogBlockQuery.java index 2daf2ca3271..4cb0d7039bc 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/filters/LogBlockQuery.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/filters/LogBlockQuery.java @@ -158,7 +158,17 @@ private BitSet partialMatch(final int[][] bitIndexes, int section) // 3. Wait for all results and cache them Map resultCache = new HashMap<>(); for (Map.Entry> entry : bitIndexResults.entrySet()) { - BitSet result = entry.getValue().get(); + BitSet result; + try { + result = entry.getValue().get(); + } catch (ExecutionException e) { + logger.warn("JSON-RPC log query failed", e.getCause()); + throw e; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.warn("JSON-RPC log query interrupted", e); + throw e; + } if (result != null) { resultCache.put(entry.getKey(), result); } diff --git a/framework/src/test/java/org/tron/core/services/filter/CachedBodyRequestWrapperTest.java b/framework/src/test/java/org/tron/core/services/filter/CachedBodyRequestWrapperTest.java deleted file mode 100644 index 813b1a61bea..00000000000 --- a/framework/src/test/java/org/tron/core/services/filter/CachedBodyRequestWrapperTest.java +++ /dev/null @@ -1,109 +0,0 @@ -package org.tron.core.services.filter; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import org.junit.Test; -import org.springframework.mock.web.MockHttpServletRequest; - -public class CachedBodyRequestWrapperTest { - - private static final byte[] BODY = "hello world".getBytes(StandardCharsets.UTF_8); - - private static byte[] readFully(javax.servlet.ServletInputStream in) throws IOException { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - byte[] buf = new byte[128]; - int n; - while ((n = in.read(buf)) != -1) { - out.write(buf, 0, n); - } - return out.toByteArray(); - } - - // --- getInputStream --- - - @Test - public void getInputStream_returnsBodyContent() throws IOException { - CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); - byte[] read = readFully(w.getInputStream()); - assertEquals(new String(BODY, StandardCharsets.UTF_8), - new String(read, StandardCharsets.UTF_8)); - } - - @Test - public void getInputStream_calledTwice_bothSucceed() throws IOException { - CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); - w.getInputStream(); - // second call of the same accessor is allowed by the servlet spec - w.getInputStream(); - } - - // --- getReader --- - - @Test - public void getReader_returnsBodyContent() throws IOException { - CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); - String line = w.getReader().readLine(); - assertEquals("hello world", line); - } - - @Test - public void getReader_calledTwice_bothSucceed() throws IOException { - CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); - w.getReader(); - w.getReader(); - } - - // --- mutual exclusion --- - - @Test(expected = IllegalStateException.class) - public void getReader_afterGetInputStream_throws() throws IOException { - CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); - w.getInputStream(); - w.getReader(); - } - - @Test(expected = IllegalStateException.class) - public void getInputStream_afterGetReader_throws() throws IOException { - CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); - w.getReader(); - w.getInputStream(); - } - - // --- stream contract --- - - @Test - public void getInputStream_isFinished_afterFullRead() throws IOException { - CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); - javax.servlet.ServletInputStream in = w.getInputStream(); - while (in.read() != -1) { - // drain - } - assertTrue(in.isFinished()); - } - - @Test - public void getInputStream_isReady_returnsTrue() { - CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); - assertTrue(w.getInputStream().isReady()); - } - - @Test - public void getInputStream_emptyBody_isFinishedImmediately() { - CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), - new byte[0]); - assertTrue(w.getInputStream().isFinished()); - } - - @Test - public void getReader_usesRequestCharacterEncoding() throws IOException { - MockHttpServletRequest req = new MockHttpServletRequest(); - req.setCharacterEncoding("UTF-8"); - byte[] utf8Body = "tron".getBytes(StandardCharsets.UTF_8); - CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(req, utf8Body); - assertEquals("tron", w.getReader().readLine()); - } -} diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcDispatchContractTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcDispatchContractTest.java new file mode 100644 index 00000000000..cd37945f85e --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcDispatchContractTest.java @@ -0,0 +1,209 @@ +package org.tron.core.services.jsonrpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.googlecode.jsonrpc4j.JsonRpcBasicServer; +import com.googlecode.jsonrpc4j.JsonRpcMethod; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.junit.Assert; +import org.junit.Test; + +/** + * Characterizes the jsonrpc4j dispatch behavior relied on by the JSON-RPC implementation. + */ +public class JsonRpcDispatchContractTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final JsonRpcBasicServer server = + new JsonRpcBasicServer(new DispatchServiceImpl(), DispatchService.class); + + @Test + public void testNullArrayElementReachesMethodBody() throws Exception { + JsonNode response = handle(request("oneArg", "[null]")); + + Assert.assertEquals("one:null", response.get("result").asText()); + Assert.assertEquals(7, response.get("id").asInt()); + } + + @Test + public void testMissingAndEmptyPositionalParametersFailArityValidation() throws Exception { + assertInvalidParams(handle("{\"jsonrpc\":\"2.0\",\"method\":\"oneArg\",\"id\":7}")); + assertInvalidParams(handle(request("oneArg", "null"))); + assertInvalidParams(handle(request("oneArg", "[]"))); + assertInvalidParams(handle(request("twoArg", "[\"a\"]"))); + assertInvalidParams(handle(request("twoArg", "[\"a\",\"b\",\"c\"]"))); + } + + @Test + public void testObjectParametersDependOnMethodArity() throws Exception { + assertInvalidParams(handle(request("oneArg", "{}"))); + + JsonNode response = handle(request("noArg", "{}")); + Assert.assertEquals("noArg", response.get("result").asText()); + + assertInvalidParams(handle(request("noArg", "{\"a\":1}"))); + } + + @Test + public void testScalarParametersEscapeAsKnownFrameworkDefect() { + Assert.assertThrows(IllegalArgumentException.class, + () -> handle(request("oneArg", "5"))); + } + + @Test + public void testStringParameterConversionContract() throws Exception { + JsonNode objectResponse = handle(request("callStr", "[\"args\",{\"blockNumber\":\"0x1\"}]")); + assertParseErrorWithLostId(objectResponse); + + JsonNode arrayResponse = handle(request("callStr", "[\"args\",[1,2]]")); + assertParseErrorWithLostId(arrayResponse); + + JsonNode numberResponse = handle(request("callStr", "[\"args\",123]")); + Assert.assertEquals("str:123", numberResponse.get("result").asText()); + Assert.assertEquals(7, numberResponse.get("id").asInt()); + } + + @Test + public void testObjectParameterPreservesRuntimeShape() throws Exception { + JsonNode objectResponse = handle(request( + "callObj", "[\"args\",{\"blockNumber\":\"0x1\"}]")); + Assert.assertEquals("obj:LinkedHashMap:0x1", objectResponse.get("result").asText()); + + JsonNode nullResponse = handle(request("callObj", "[\"args\",null]")); + Assert.assertEquals("obj:null", nullResponse.get("result").asText()); + + JsonNode stringResponse = handle(request("callObj", "[\"args\",\"latest\"]")); + Assert.assertEquals("obj:String:latest", stringResponse.get("result").asText()); + } + + @Test + public void testOverloadSelectionUsesParameterCount() throws Exception { + Assert.assertEquals("over1:a", + handle(request("over", "[\"a\"]")).get("result").asText()); + Assert.assertEquals("over2:a,b", + handle(request("over", "[\"a\",\"b\"]")).get("result").asText()); + Assert.assertEquals("over2:a,null", + handle(request("over", "[\"a\",null]")).get("result").asText()); + Assert.assertEquals("over1:null", + handle(request("over", "[null]")).get("result").asText()); + assertInvalidParams(handle(request("over", "[]"))); + assertInvalidParams(handle("{\"jsonrpc\":\"2.0\",\"method\":\"over\",\"id\":7}")); + } + + @Test + public void testNativeBatchIsolatesScalarParameterDefect() throws Exception { + JsonNode response = handle("[" + + request("oneArg", "5") + "," + + requestWithId("oneArg", "[\"ok\"]", 8) + "]"); + + Assert.assertTrue(response.isArray()); + Assert.assertEquals(2, response.size()); + assertParseErrorWithLostId(response.get(0)); + Assert.assertEquals("one:ok", response.get(1).get("result").asText()); + Assert.assertEquals(8, response.get(1).get("id").asInt()); + } + + private JsonNode handle(String json) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + server.handleRequest( + new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)), output); + return MAPPER.readTree(output.toByteArray()); + } + + private static String request(String method, String params) { + return requestWithId(method, params, 7); + } + + private static String requestWithId(String method, String params, int id) { + return "{\"jsonrpc\":\"2.0\",\"method\":\"" + method + + "\",\"params\":" + params + ",\"id\":" + id + "}"; + } + + private static void assertInvalidParams(JsonNode response) { + JsonNode error = response.get("error"); + Assert.assertEquals(-32602, error.get("code").asInt()); + Assert.assertEquals("method parameters invalid", error.get("message").asText()); + Assert.assertEquals(7, response.get("id").asInt()); + } + + private static void assertParseErrorWithLostId(JsonNode response) { + JsonNode error = response.get("error"); + Assert.assertEquals(-32700, error.get("code").asInt()); + Assert.assertEquals("JSON parse error", error.get("message").asText()); + Assert.assertTrue(response.get("id").isTextual()); + Assert.assertEquals("null", response.get("id").asText()); + } + + public interface DispatchService { + + @JsonRpcMethod("noArg") + String noArg(); + + @JsonRpcMethod("oneArg") + String oneArg(String value); + + @JsonRpcMethod("twoArg") + String twoArg(String first, String second); + + @JsonRpcMethod("over") + String over(String value); + + @JsonRpcMethod("over") + String over(String first, String second); + + @JsonRpcMethod("callObj") + String callObj(Object arguments, Object block); + + @JsonRpcMethod("callStr") + String callStr(Object arguments, String block); + } + + public static class DispatchServiceImpl implements DispatchService { + + @Override + public String noArg() { + return "noArg"; + } + + @Override + public String oneArg(String value) { + return "one:" + value; + } + + @Override + public String twoArg(String first, String second) { + return "two:" + first + "," + second; + } + + @Override + public String over(String value) { + return "over1:" + value; + } + + @Override + public String over(String first, String second) { + return "over2:" + first + "," + second; + } + + @Override + public String callObj(Object arguments, Object block) { + if (block == null) { + return "obj:null"; + } + if (block instanceof Map) { + return "obj:" + block.getClass().getSimpleName() + ":" + + ((Map) block).get("blockNumber"); + } + return "obj:" + block.getClass().getSimpleName() + ":" + block; + } + + @Override + public String callStr(Object arguments, String block) { + return "str:" + block; + } + } +} diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolverTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolverTest.java index d8e64308ab8..9237095300d 100644 --- a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolverTest.java +++ b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcErrorResolverTest.java @@ -1,15 +1,24 @@ package org.tron.core.services.jsonrpc; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.core.read.ListAppender; import com.fasterxml.jackson.databind.JsonNode; -import com.googlecode.jsonrpc4j.ErrorData; import com.googlecode.jsonrpc4j.ErrorResolver.JsonError; import com.googlecode.jsonrpc4j.JsonRpcError; import com.googlecode.jsonrpc4j.JsonRpcErrors; +import com.googlecode.jsonrpc4j.JsonRpcMethod; import java.lang.reflect.Method; -import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.concurrent.ExecutionException; import org.junit.Assert; +import org.junit.Before; import org.junit.Test; +import org.slf4j.LoggerFactory; +import org.tron.core.exception.TronError; import org.tron.core.exception.jsonrpc.JsonRpcException; import org.tron.core.exception.jsonrpc.JsonRpcInternalException; import org.tron.core.exception.jsonrpc.JsonRpcInvalidParamsException; @@ -17,27 +26,51 @@ public class JsonRpcErrorResolverTest { + private static final List NO_ARGUMENTS = Collections.emptyList(); + private final JsonRpcErrorResolver resolver = JsonRpcErrorResolver.INSTANCE; @JsonRpcErrors({ @JsonRpcError(exception = JsonRpcInvalidRequestException.class, code = -32600, data = "{}"), @JsonRpcError(exception = JsonRpcInvalidParamsException.class, code = -32602, data = "{}"), @JsonRpcError(exception = JsonRpcInternalException.class, code = -32000, data = "{}"), + @JsonRpcError(exception = ExecutionException.class, code = -32000, data = "{}"), @JsonRpcError(exception = JsonRpcException.class, code = -1) - }) + }) public void dummyMethod() { } - @Test - public void testResolveErrorWithTronException() throws Exception { + @JsonRpcErrors({ + @JsonRpcError(exception = IllegalArgumentException.class, code = -32602, + message = "annotation message"), + @JsonRpcError(exception = NullPointerException.class, code = -32602), + @JsonRpcError(exception = IllegalStateException.class, code = -32600), + @JsonRpcError(exception = UnsupportedOperationException.class, code = -32601), + @JsonRpcError(exception = RuntimeException.class, code = -32000) + }) + public void messageMethod() { + } - String message = "JsonRpcInvalidRequestException"; + @JsonRpcMethod("test_unmapped") + public void unmappedMethod() { + } + @JsonRpcMethod("test_unmapped_other") + public void otherUnmappedMethod() { + } + + @Before + public void clearSeenFailures() { + JsonRpcErrorResolver.clearSeenFailuresForTest(); + } + + @Test + public void testMappedErrorsPreserveCodeAndDataPriority() throws Exception { + String message = "JsonRpcInvalidRequestException"; JsonRpcException exception = new JsonRpcInvalidRequestException(message); - Method method = this.getClass().getMethod("dummyMethod"); - List arguments = new ArrayList<>(); + Method method = getClass().getMethod("dummyMethod"); - JsonError error = resolver.resolveError(exception, method, arguments); + JsonError error = resolver.resolveError(exception, method, NO_ARGUMENTS); Assert.assertNotNull(error); Assert.assertEquals(-32600, error.code); Assert.assertEquals(message, error.message); @@ -46,7 +79,7 @@ public void testResolveErrorWithTronException() throws Exception { message = "JsonRpcInternalException"; String data = "JsonRpcInternalException data"; exception = new JsonRpcInternalException(message, data); - error = resolver.resolveError(exception, method, arguments); + error = resolver.resolveError(exception, method, NO_ARGUMENTS); Assert.assertNotNull(error); Assert.assertEquals(-32000, error.code); @@ -54,7 +87,7 @@ public void testResolveErrorWithTronException() throws Exception { Assert.assertEquals(data, error.data); exception = new JsonRpcInternalException(message, null); - error = resolver.resolveError(exception, method, arguments); + error = resolver.resolveError(exception, method, NO_ARGUMENTS); Assert.assertNotNull(error); Assert.assertEquals(-32000, error.code); @@ -63,13 +96,251 @@ public void testResolveErrorWithTronException() throws Exception { message = "JsonRpcException"; exception = new JsonRpcException(message, null); - error = resolver.resolveError(exception, method, arguments); + error = resolver.resolveError(exception, method, NO_ARGUMENTS); Assert.assertNotNull(error); Assert.assertEquals(-1, error.code); Assert.assertEquals(message, error.message); - Assert.assertTrue(error.data instanceof ErrorData); + Assert.assertNull(error.data); + } + + @Test + public void testUnmappedExceptionsUseSanitizedInternalError() throws Exception { + Method method = getClass().getMethod("unmappedMethod"); + JsonError error = resolver.resolveError( + new RuntimeException("sensitive-marker"), method, NO_ARGUMENTS); + + assertInternalError(error); + } + + @Test + public void testUnmappedExceptionLoggingIsBoundedByMethodAndType() throws Exception { + Logger apiLogger = (Logger) LoggerFactory.getLogger("API"); + Level originalLevel = apiLogger.getLevel(); + ListAppender appender = new ListAppender<>(); + appender.start(); + apiLogger.addAppender(appender); + apiLogger.setLevel(Level.DEBUG); + + try { + Method method = getClass().getMethod("unmappedMethod"); + resolver.resolveError(new RuntimeException("first-sensitive-marker"), method, NO_ARGUMENTS); + resolver.resolveError(new RuntimeException("second-sensitive-marker"), method, NO_ARGUMENTS); + resolver.resolveError(new IllegalStateException("third-sensitive-marker"), method, + NO_ARGUMENTS); + resolver.resolveError(new RuntimeException("fourth-sensitive-marker"), + getClass().getMethod("otherUnmappedMethod"), NO_ARGUMENTS); + + Assert.assertEquals(3, countEvents(appender, Level.WARN)); + Assert.assertEquals(1, countEvents(appender, Level.DEBUG)); + + ILoggingEvent firstWarning = findEvent(appender, Level.WARN, "test_unmapped"); + Assert.assertNotNull(firstWarning); + IThrowableProxy throwable = firstWarning.getThrowableProxy(); + Assert.assertNotNull(throwable); + Assert.assertEquals(RuntimeException.class.getName(), throwable.getClassName()); + Assert.assertEquals("first-sensitive-marker", throwable.getMessage()); + + ILoggingEvent repeated = findEvent(appender, Level.DEBUG, "test_unmapped"); + Assert.assertNotNull(repeated); + Assert.assertNull(repeated.getThrowableProxy()); + Assert.assertFalse(repeated.getFormattedMessage().contains("second-sensitive-marker")); + } finally { + apiLogger.setLevel(originalLevel); + apiLogger.detachAppender(appender); + appender.stop(); + } + } + + @Test + public void testNullMethodUsesSanitizedInternalError() { + JsonError error = resolver.resolveError( + new RuntimeException("sensitive-marker"), null, NO_ARGUMENTS); + + assertInternalError(error); + } + + @Test + public void testMappedMessagePriorityAndDefaults() throws Exception { + Method method = getClass().getMethod("messageMethod"); + + JsonError error = resolver.resolveError( + new IllegalArgumentException("exception message"), method, NO_ARGUMENTS); + Assert.assertEquals("annotation message", error.message); + + error = resolver.resolveError( + new RuntimeException("filter not found"), method, NO_ARGUMENTS); + Assert.assertEquals("filter not found", error.message); + + error = resolver.resolveError(new NullPointerException(), method, NO_ARGUMENTS); + Assert.assertEquals("Invalid params", error.message); + + error = resolver.resolveError(new IllegalStateException(" "), method, NO_ARGUMENTS); + Assert.assertEquals("Invalid Request", error.message); + + error = resolver.resolveError(new UnsupportedOperationException(), method, NO_ARGUMENTS); + Assert.assertEquals("Method not found", error.message); + + error = resolver.resolveError(new RuntimeException(), method, NO_ARGUMENTS); + Assert.assertEquals("Internal error", error.message); + } + + @Test + public void testNonFatalErrorUsesSanitizedInternalError() throws Exception { + Method method = getClass().getMethod("unmappedMethod"); + JsonError error = resolver.resolveError(new AssertionError("sensitive-marker"), + method, NO_ARGUMENTS); + + assertInternalError(error); + } + + @Test + public void testFatalErrorsPropagate() throws Exception { + Method method = getClass().getMethod("unmappedMethod"); + + assertFatalPropagates(new StackOverflowError("fatal-marker"), method); + assertFatalPropagates(new ThreadDeath(), method); + assertFatalPropagates(new LinkageError("fatal-marker"), method); + assertFatalPropagates( + new TronError("fatal-marker", TronError.ErrCode.API_SERVER_INIT), method); + } + + @Test + public void testWrappedFatalErrorPropagatesActualCause() throws Exception { + Method method = getClass().getMethod("dummyMethod"); + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + + Error thrown = Assert.assertThrows(Error.class, + () -> resolver.resolveError(new ExecutionException(fatal), method, NO_ARGUMENTS)); + + Assert.assertSame(fatal, thrown); + } + + @Test(timeout = 5000) + public void testCyclicCauseChainTerminates() throws Exception { + Method method = getClass().getMethod("unmappedMethod"); + CyclicException first = new CyclicException("first"); + CyclicException second = new CyclicException("second"); + first.setNext(second); + second.setNext(first); + + JsonError error = resolver.resolveError(first, method, NO_ARGUMENTS); + + assertInternalError(error); + } + + @Test(timeout = 5000) + public void testFindFatalCauseWithNull() { + Assert.assertNull(JsonRpcErrorResolver.findFatalCause(null)); + } + + @Test(timeout = 5000) + public void testFindFatalCauseWithOrdinaryChain() { + Throwable chain = new Exception("outer", new Exception("middle", new Exception("inner"))); + + Assert.assertNull(JsonRpcErrorResolver.findFatalCause(chain)); + } + + @Test(timeout = 5000) + public void testFindFatalCauseAtEndOfChain() { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + Throwable chain = new Exception("outer", new Exception("middle", fatal)); + + Assert.assertSame(fatal, JsonRpcErrorResolver.findFatalCause(chain)); + } + + @Test(timeout = 5000) + public void testFindFatalCauseWithWrappedTronError() { + TronError fatal = new TronError("fatal-marker", TronError.ErrCode.API_SERVER_INIT); + + Assert.assertSame(fatal, JsonRpcErrorResolver.findFatalCause(new Exception("outer", fatal))); + } + + @Test(timeout = 5000) + public void testFindFatalCauseWithOrdinaryCycle() { + Exception first = new Exception("first"); + Exception second = new Exception("second"); + first.initCause(second); + second.initCause(first); + + Assert.assertNull(JsonRpcErrorResolver.findFatalCause(first)); + } + + @Test(timeout = 5000) + public void testFindFatalCauseInsideCycleAfterPrefix() { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + Throwable[] chain = new Throwable[12]; + for (int i = 0; i < chain.length; i++) { + chain[i] = i == 8 ? fatal : new Exception("cause-" + i); + } + for (int i = 0; i < chain.length - 1; i++) { + chain[i].initCause(chain[i + 1]); + } + chain[11].initCause(chain[6]); + + Assert.assertSame(fatal, JsonRpcErrorResolver.findFatalCause(chain[0])); + } + + @Test(timeout = 5000) + public void testFindFatalCauseBeyondWrapperDepthLimit() { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + Throwable chain = fatal; + for (int i = 0; i < 64; i++) { + chain = new Exception("cause-" + i, chain); + } + + Assert.assertSame(fatal, JsonRpcErrorResolver.findFatalCause(chain)); + } + + private void assertFatalPropagates(Error fatal, Method method) { + Error thrown = Assert.assertThrows(Error.class, + () -> resolver.resolveError(fatal, method, NO_ARGUMENTS)); + Assert.assertSame(fatal, thrown); + } + + private static void assertInternalError(JsonError error) { + Assert.assertNotNull(error); + Assert.assertEquals(-32603, error.code); + Assert.assertEquals("Internal error", error.message); + Assert.assertNull(error.data); + } + + private static ILoggingEvent findEvent(ListAppender appender, Level level, + String marker) { + for (ILoggingEvent event : appender.list) { + if (event.getLevel() == level && event.getFormattedMessage().contains(marker)) { + return event; + } + } + return null; + } + + private static int countEvents(ListAppender appender, Level level) { + int count = 0; + for (ILoggingEvent event : appender.list) { + if (event.getLevel() == level) { + count++; + } + } + return count; + } + + private static class CyclicException extends RuntimeException { + + private Throwable next; + + CyclicException(String message) { + super(message, null); + } + + void setNext(Throwable next) { + this.next = next; + } + @Override + public synchronized Throwable getCause() { + return next; + } } -} \ No newline at end of file +} diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcErrorSanitizationIntegrationTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcErrorSanitizationIntegrationTest.java new file mode 100644 index 00000000000..e9ba99cdd66 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcErrorSanitizationIntegrationTest.java @@ -0,0 +1,729 @@ +package org.tron.core.services.jsonrpc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.googlecode.jsonrpc4j.JsonRpcInterceptor; +import com.googlecode.jsonrpc4j.JsonRpcMethod; +import com.googlecode.jsonrpc4j.JsonRpcServer; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutionException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.tron.common.parameter.CommonParameter; +import org.tron.core.exception.ItemNotFoundException; +import org.tron.core.exception.TronError; +import org.tron.core.exception.jsonrpc.JsonRpcInternalException; + +public class JsonRpcErrorSanitizationIntegrationTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String SENSITIVE_MARKER = "sensitive-marker"; + + private int savedMaxBatchSize; + private int savedMaxResponseSize; + + @Before + public void setUp() { + CommonParameter parameter = CommonParameter.getInstance(); + savedMaxBatchSize = parameter.jsonRpcMaxBatchSize; + savedMaxResponseSize = parameter.jsonRpcMaxResponseSize; + parameter.jsonRpcMaxBatchSize = 0; + parameter.jsonRpcMaxResponseSize = 0; + } + + @After + public void tearDown() { + CommonParameter parameter = CommonParameter.getInstance(); + parameter.jsonRpcMaxBatchSize = savedMaxBatchSize; + parameter.jsonRpcMaxResponseSize = savedMaxResponseSize; + } + + @Test + public void testUnmappedExceptionIsSanitizedOnWire() throws Exception { + JsonRpcServer server = newServer(new ErrorServiceImpl(), ErrorService.class); + + JsonNode response = handle(server, request("test_unhandled", "[]", 1)); + + assertSanitizedInternalError(response, 1); + assertNoInternalDetails(response.toString()); + } + + @Test + public void testNonFatalErrorIsSanitizedByRealServer() throws Exception { + JsonRpcServer server = newServer(new ErrorServiceImpl(), ErrorService.class); + + JsonNode response = handle(server, request("test_assertion", "[]", 1)); + + assertSanitizedInternalError(response, 1); + assertNoInternalDetails(response.toString()); + } + + @Test + public void testPreHandleJsonAssertionErrorEscapesRealServer() throws Exception { + AssertionError failure = new AssertionError(SENSITIVE_MARKER); + FailingInterceptor interceptor = new FailingInterceptor(2, failure); + JsonRpcServer server = newServer(new ErrorServiceImpl(), ErrorService.class, + Collections.singletonList(interceptor)); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + Assert.assertSame(failure, Assert.assertThrows(AssertionError.class, + () -> server.handleRequest(new ByteArrayInputStream( + request("test_ok", "[]", 2).getBytes(StandardCharsets.UTF_8)), output))); + + Assert.assertEquals(Collections.singletonList(2), interceptor.dispatched); + Assert.assertEquals(0, output.size()); + } + + @Test + public void testServletSingleRecoversInterceptorAssertionError() throws Exception { + FailingInterceptor interceptor = + new FailingInterceptor(2, new AssertionError(SENSITIVE_MARKER)); + TestableServlet servlet = newServlet(newServer(new ErrorServiceImpl(), ErrorService.class, + Collections.singletonList(interceptor))); + + MockHttpServletResponse response = post(servlet, request("test_ok", "[]", 2)); + + assertJsonRpcResponse(response, errorResponse(-32603, "Internal error", "2")); + Assert.assertEquals(Collections.singletonList(2), interceptor.dispatched); + } + + @Test + public void testServletBatchRecoversInterceptorAssertionError() throws Exception { + FailingInterceptor interceptor = + new FailingInterceptor(2, new AssertionError(SENSITIVE_MARKER)); + TestableServlet servlet = newServlet(newServer(new ErrorServiceImpl(), ErrorService.class, + Collections.singletonList(interceptor))); + + MockHttpServletResponse response = post(servlet, + "[" + request("test_ok", "[]", 1) + "," + request("test_ok", "[]", 2) + + "," + request("test_ok", "[]", 3) + "]"); + + assertJsonRpcResponse(response, MAPPER.createArrayNode() + .add(MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":1}")) + .add(errorResponse(-32603, "Internal error", "2")) + .add(MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":3}"))); + Assert.assertEquals(Arrays.asList(1, 2, 3), interceptor.dispatched); + } + + @Test + public void testServletSinglePropagatesInterceptorFatalError() throws Exception { + TronError fatal = new TronError("fatal-marker", TronError.ErrCode.API_SERVER_INIT); + assertInterceptorFatalPropagates(fatal, fatal, false); + } + + @Test + public void testServletBatchPropagatesInterceptorFatalError() throws Exception { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + assertInterceptorFatalPropagates(fatal, fatal, true); + } + + @Test + public void testServletSinglePropagatesInterceptorWrappedFatalError() throws Exception { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + assertInterceptorFatalPropagates(new AssertionError("wrapper-marker", fatal), fatal, false); + } + + @Test + public void testServletBatchPropagatesInterceptorWrappedFatalError() throws Exception { + TronError fatal = new TronError("fatal-marker", TronError.ErrCode.API_SERVER_INIT); + assertInterceptorFatalPropagates(new AssertionError("wrapper-marker", fatal), fatal, true); + } + + @Test + public void testServletForwardsUnknownMethodNotificationError() throws Exception { + assertNotificationErrorIsForwarded("test_missing", -32601, "method not found"); + } + + @Test + public void testServletForwardsArityNotificationError() throws Exception { + assertNotificationErrorIsForwarded("test_echo", -32602, "method parameters invalid"); + } + + @Test + public void testServletForwardsUnmappedNotificationError() throws Exception { + assertNotificationErrorIsForwarded("test_unhandled", -32603, "Internal error"); + } + + @Test + public void testFatalErrorsEscapeRealServer() throws Exception { + ErrorServiceImpl service = new ErrorServiceImpl(); + JsonRpcServer server = newServer(service, ErrorService.class); + + StackOverflowError direct = Assert.assertThrows(StackOverflowError.class, + () -> handle(server, request("test_fatal", "[]", 1))); + Assert.assertSame(service.directFatal, direct); + + TronError tronError = Assert.assertThrows(TronError.class, + () -> handle(server, request("test_tron_error", "[]", 2))); + Assert.assertSame(service.tronError, tronError); + + TronJsonRpc mappedService = mock(TronJsonRpc.class); + StackOverflowError wrappedFatal = new StackOverflowError("wrapped-fatal-marker"); + when(mappedService.getLogs(any(TronJsonRpc.FilterRequest.class))) + .thenThrow(new ExecutionException(wrappedFatal)); + JsonRpcServer mappedServer = newServer(mappedService, TronJsonRpc.class); + + StackOverflowError wrapped = Assert.assertThrows(StackOverflowError.class, + () -> handle(mappedServer, request("eth_getLogs", "[{}]", 2))); + Assert.assertSame(wrappedFatal, wrapped); + } + + @Test + public void testMappedExecutionExceptionUsesFixedMessage() throws Exception { + TronJsonRpc service = mock(TronJsonRpc.class); + when(service.getLogs(any(TronJsonRpc.FilterRequest.class))) + .thenThrow(new ExecutionException(new NullPointerException(SENSITIVE_MARKER))); + JsonRpcServer server = newServer(service, TronJsonRpc.class); + + JsonNode response = handle(server, request("eth_getLogs", "[{}]", 1)); + + assertMappedInternalError(response, 1); + assertNoInternalDetails(response.toString()); + } + + @Test + public void testMappedInterruptedExceptionUsesFixedMessage() throws Exception { + Thread.interrupted(); + try { + TronJsonRpc service = mock(TronJsonRpc.class); + when(service.getLogs(any(TronJsonRpc.FilterRequest.class))).thenAnswer(invocation -> { + Thread.currentThread().interrupt(); + throw new InterruptedException(); + }); + JsonRpcServer server = newServer(service, TronJsonRpc.class); + + JsonNode response = handle(server, request("eth_getLogs", "[{}]", 1)); + + assertMappedInternalError(response, 1); + Assert.assertFalse(response.toString().contains("InterruptedException")); + Assert.assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + } + + @Test + public void testGetFilterLogsAsyncFailuresUseFixedMessage() throws Exception { + TronJsonRpc service = mock(TronJsonRpc.class); + when(service.getFilterLogs("0xdeadbeef")) + .thenThrow(new ExecutionException(new NullPointerException(SENSITIVE_MARKER))) + .thenThrow(new InterruptedException()); + JsonRpcServer server = newServer(service, TronJsonRpc.class); + + JsonNode executionResponse = handle(server, + request("eth_getFilterLogs", "[\"0xdeadbeef\"]", 1)); + JsonNode interruptedResponse = handle(server, + request("eth_getFilterLogs", "[\"0xdeadbeef\"]", 2)); + + assertMappedInternalError(executionResponse, 1); + assertMappedInternalError(interruptedResponse, 2); + assertNoInternalDetails(executionResponse.toString()); + Assert.assertFalse(interruptedResponse.toString().contains("InterruptedException")); + } + + @Test + public void testChainIdentityKeepsDocumentedCodeAndSanitizesDetails() throws Exception { + TronJsonRpc service = mock(TronJsonRpc.class); + // Shaped like production: the exception carries a cause, so the response must withhold the + // exception message, the cause message and both type names. + when(service.ethChainId()).thenThrow( + new JsonRpcInternalException(SENSITIVE_MARKER, new RuntimeException(SENSITIVE_MARKER))); + when(service.getNetVersion()).thenThrow( + new JsonRpcInternalException(SENSITIVE_MARKER, new RuntimeException(SENSITIVE_MARKER))); + JsonRpcServer server = newServer(service, TronJsonRpc.class); + + JsonNode chainId = handle(server, request("eth_chainId", "[]", 1)); + JsonNode netVersion = handle(server, request("net_version", "[]", 2)); + + // -32001 is documented as JSON_RPC_UNDERLYING_INTERNAL_ERROR for chain identity lookups, + // so the code is kept while the underlying message and exception type are withheld. + assertChainIdentityError(chainId, 1); + assertChainIdentityError(netVersion, 2); + assertNoInternalDetails(chainId.toString()); + assertNoInternalDetails(netVersion.toString()); + } + + @Test + public void testMappedBusinessMessageIsPreserved() throws Exception { + TronJsonRpc service = mock(TronJsonRpc.class); + when(service.getFilterLogs("0xdeadbeef")) + .thenThrow(new ItemNotFoundException("filter not found")); + JsonRpcServer server = newServer(service, TronJsonRpc.class); + + JsonNode response = handle(server, + request("eth_getFilterLogs", "[\"0xdeadbeef\"]", 1)); + + JsonNode error = response.get("error"); + Assert.assertEquals(-32000, error.get("code").asInt()); + Assert.assertEquals("filter not found", error.get("message").asText()); + Assert.assertEquals("{}", error.get("data").asText()); + } + + @Test + public void testServletBatchIsolatesInvalidElement() throws Exception { + TestableServlet servlet = newServlet(new ErrorServiceImpl()); + + MockHttpServletResponse response = post(servlet, + "[null," + request("test_ok", "[]", 2) + "]"); + JsonNode body = MAPPER.readTree(response.getContentAsByteArray()); + + Assert.assertTrue(body.isArray()); + Assert.assertEquals(2, body.size()); + Assert.assertEquals(-32600, body.get(0).get("error").get("code").asInt()); + Assert.assertTrue(body.get(0).get("id").isNull()); + Assert.assertEquals("ok", body.get(1).get("result").asText()); + Assert.assertEquals(2, body.get(1).get("id").asInt()); + } + + @Test + public void testServletBatchIsolatesUnhandledException() throws Exception { + TestableServlet servlet = newServlet(new ErrorServiceImpl()); + + MockHttpServletResponse response = post(servlet, + "[" + request("test_unhandled", "[]", 1) + "," + + request("test_ok", "[]", 2) + "]"); + JsonNode body = MAPPER.readTree(response.getContentAsByteArray()); + + Assert.assertTrue(body.isArray()); + Assert.assertEquals(2, body.size()); + assertSanitizedInternalError(body.get(0), 1); + Assert.assertEquals("ok", body.get(1).get("result").asText()); + Assert.assertEquals(2, body.get(1).get("id").asInt()); + assertNoInternalDetails(body.toString()); + } + + @Test + public void testServletSingleRequestPreservesTransportContract() throws Exception { + TestableServlet servlet = newServlet(new ErrorServiceImpl()); + + MockHttpServletResponse response = post(servlet, + request("test_ok", "[]", 1)); + JsonNode body = MAPPER.readTree(response.getContentAsByteArray()); + + Assert.assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + Assert.assertEquals("application/json-rpc", response.getContentType()); + Assert.assertEquals("ok", body.get("result").asText()); + Assert.assertEquals(1, body.get("id").asInt()); + } + + @Test + public void testServletSingleNotificationReturnsEmptyBody() throws Exception { + TestableServlet servlet = newServlet(new ErrorServiceImpl()); + + MockHttpServletResponse response = post(servlet, + "{\"jsonrpc\":\"2.0\",\"method\":\"test_ok\",\"params\":[]}"); + + Assert.assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + Assert.assertEquals("application/json-rpc", response.getContentType()); + Assert.assertEquals(0, response.getContentAsByteArray().length); + } + + @Test + public void testServletRejectsNonNullScalarParamsAsInvalidRequest() throws Exception { + TestableServlet servlet = newServlet(new ErrorServiceImpl()); + String[] scalarParams = {"5", "\"value\"", "true"}; + + for (int i = 0; i < scalarParams.length; i++) { + int id = i + 1; + MockHttpServletResponse response = post(servlet, + request("test_ok", scalarParams[i], id)); + JsonNode body = MAPPER.readTree(response.getContentAsByteArray()); + + Assert.assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + Assert.assertEquals("application/json-rpc", response.getContentType()); + assertInvalidRequest(body); + Assert.assertEquals(MAPPER.getNodeFactory().numberNode(id), body.get("id")); + } + } + + @Test + public void testServletScalarParamsApplyRequestIdRules() throws Exception { + TestableServlet servlet = newServlet(new ErrorServiceImpl()); + + MockHttpServletResponse stringIdResponse = post(servlet, + "{\"jsonrpc\":\"2.0\",\"method\":\"test_ok\",\"params\":5," + + "\"id\":\"request-1\"}"); + assertInvalidRequestResponse(stringIdResponse, + MAPPER.getNodeFactory().textNode("request-1")); + + MockHttpServletResponse missingIdResponse = post(servlet, + "{\"jsonrpc\":\"2.0\",\"method\":\"test_ok\",\"params\":5}"); + assertInvalidRequestResponse(missingIdResponse, MAPPER.getNodeFactory().nullNode()); + + MockHttpServletResponse nullIdResponse = post(servlet, + "{\"jsonrpc\":\"2.0\",\"method\":\"test_ok\",\"params\":5,\"id\":null}"); + assertInvalidRequestResponse(nullIdResponse, MAPPER.getNodeFactory().nullNode()); + + MockHttpServletResponse invalidIdResponse = post(servlet, + "{\"jsonrpc\":\"2.0\",\"method\":\"test_ok\",\"params\":5,\"id\":true}"); + assertInvalidRequestResponse(invalidIdResponse, MAPPER.getNodeFactory().nullNode()); + } + + @Test + public void testServletValidatesScalarParamsBeforeMethodLookup() throws Exception { + TestableServlet servlet = newServlet(new ErrorServiceImpl()); + + MockHttpServletResponse scalarResponse = post(servlet, + request("test_unknown", "5", 1)); + JsonNode scalarBody = MAPPER.readTree(scalarResponse.getContentAsByteArray()); + assertInvalidRequest(scalarBody); + Assert.assertEquals(MAPPER.getNodeFactory().numberNode(1), scalarBody.get("id")); + + MockHttpServletResponse structuredResponse = post(servlet, + request("test_unknown", "[]", 2)); + JsonNode structuredBody = MAPPER.readTree(structuredResponse.getContentAsByteArray()); + JsonNode structuredError = structuredBody.get("error"); + Assert.assertEquals(MAPPER.getNodeFactory().numberNode(-32601), + structuredError.get("code")); + Assert.assertEquals(MAPPER.getNodeFactory().textNode("method not found"), + structuredError.get("message")); + Assert.assertFalse(structuredError.has("data")); + Assert.assertEquals(MAPPER.getNodeFactory().numberNode(2), structuredBody.get("id")); + } + + @Test + public void testServletForwardsNullArrayAndObjectParamsToDispatch() throws Exception { + TestableServlet servlet = newServlet(new ErrorServiceImpl()); + + MockHttpServletResponse noArgResponse = post(servlet, + request("test_ok", "null", 1)); + JsonNode noArgBody = MAPPER.readTree(noArgResponse.getContentAsByteArray()); + Assert.assertEquals(MAPPER.getNodeFactory().textNode("ok"), noArgBody.get("result")); + Assert.assertEquals(MAPPER.getNodeFactory().numberNode(1), noArgBody.get("id")); + + MockHttpServletResponse oneArgResponse = post(servlet, + request("test_echo", "[]", 2)); + JsonNode oneArgBody = MAPPER.readTree(oneArgResponse.getContentAsByteArray()); + JsonNode error = oneArgBody.get("error"); + Assert.assertEquals(MAPPER.getNodeFactory().numberNode(-32602), error.get("code")); + Assert.assertEquals(MAPPER.getNodeFactory().textNode("method parameters invalid"), + error.get("message")); + Assert.assertEquals(MAPPER.getNodeFactory().numberNode(2), oneArgBody.get("id")); + + MockHttpServletResponse objectResponse = post(servlet, + request("test_ok", "{}", 3)); + JsonNode objectBody = MAPPER.readTree(objectResponse.getContentAsByteArray()); + Assert.assertEquals(MAPPER.getNodeFactory().textNode("ok"), objectBody.get("result")); + Assert.assertEquals(MAPPER.getNodeFactory().numberNode(3), objectBody.get("id")); + } + + @Test + public void testFatalErrorEscapesServlet() { + ErrorServiceImpl service = new ErrorServiceImpl(); + TestableServlet servlet = newServlet(service); + MockHttpServletResponse response = new MockHttpServletResponse(); + + StackOverflowError thrown = Assert.assertThrows(StackOverflowError.class, + () -> post(servlet, request("test_fatal", "[]", 1), response)); + + Assert.assertSame(service.directFatal, thrown); + assertBareInternalServerError(response); + } + + @Test + public void testMappedFatalErrorEscapesServlet() throws Exception { + TronJsonRpc service = mock(TronJsonRpc.class); + StackOverflowError fatal = new StackOverflowError("wrapped-fatal-marker"); + when(service.getLogs(any(TronJsonRpc.FilterRequest.class))) + .thenThrow(new ExecutionException(fatal)); + TestableServlet servlet = newServlet(service, TronJsonRpc.class); + MockHttpServletResponse response = new MockHttpServletResponse(); + + StackOverflowError thrown = Assert.assertThrows(StackOverflowError.class, + () -> post(servlet, request("eth_getLogs", "[{}]", 1), response)); + + Assert.assertSame(fatal, thrown); + assertBareInternalServerError(response); + } + + @Test + public void testFatalErrorDiscardsAccumulatedBatchResponse() { + ErrorServiceImpl service = new ErrorServiceImpl(); + TestableServlet servlet = newServlet(service); + MockHttpServletResponse response = new MockHttpServletResponse(); + + StackOverflowError thrown = Assert.assertThrows(StackOverflowError.class, + () -> post(servlet, + "[" + request("test_ok", "[]", 1) + "," + + request("test_fatal", "[]", 2) + "]", response)); + + Assert.assertSame(service.directFatal, thrown); + assertBareInternalServerError(response); + } + + private static JsonRpcServer newServer(Object service, Class serviceInterface) { + return newServer(service, serviceInterface, Collections.emptyList()); + } + + private static JsonRpcServer newServer(Object service, Class serviceInterface, + List interceptors) { + JsonRpcServer server = new JsonRpcServer(service, serviceInterface); + server.setErrorResolver(JsonRpcErrorResolver.INSTANCE); + server.setShouldLogInvocationErrors(false); + server.setInterceptorList(interceptors); + return server; + } + + private static TestableServlet newServlet(ErrorServiceImpl service) { + return newServlet(service, ErrorService.class); + } + + private static TestableServlet newServlet(Object service, Class serviceInterface) { + return newServlet(newServer(service, serviceInterface)); + } + + private static TestableServlet newServlet(JsonRpcServer server) { + TestableServlet servlet = new TestableServlet(); + servlet.setRpcServer(server); + return servlet; + } + + private static JsonNode handle(JsonRpcServer server, String json) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + server.handleRequest( + new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)), output); + return MAPPER.readTree(output.toByteArray()); + } + + private static MockHttpServletResponse post(TestableServlet servlet, String body) + throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + post(servlet, body, response); + return response; + } + + private static void post(TestableServlet servlet, String body, + MockHttpServletResponse response) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/jsonrpc"); + request.setContent(body.getBytes(StandardCharsets.UTF_8)); + servlet.callDoPost(request, response); + } + + private static String request(String method, String params, int id) { + return "{\"jsonrpc\":\"2.0\",\"method\":\"" + method + + "\",\"params\":" + params + ",\"id\":" + id + "}"; + } + + private static void assertNotificationErrorIsForwarded(String method, int code, String message) + throws Exception { + TestableServlet servlet = newServlet(new ErrorServiceImpl()); + String notification = "{\"jsonrpc\":\"2.0\",\"method\":\"" + method + "\",\"params\":[]}"; + JsonNode expected = errorResponse(code, message, "null"); + + assertJsonRpcResponse(post(servlet, notification), expected); + assertJsonRpcResponse(post(servlet, + "[" + notification + "," + request("test_ok", "[]", 3) + "]"), + MAPPER.createArrayNode().add(expected) + .add(MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":3}"))); + } + + private static JsonNode errorResponse(int code, String message, String id) throws IOException { + return MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":" + code + + ",\"message\":\"" + message + "\"},\"id\":" + id + "}"); + } + + private static void assertJsonRpcResponse(MockHttpServletResponse response, JsonNode expected) + throws IOException { + Assert.assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + Assert.assertEquals("application/json-rpc", response.getContentType()); + Assert.assertEquals(expected, MAPPER.reader() + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readTree(response.getContentAsByteArray())); + assertNoInternalDetails(response.getContentAsString()); + } + + private static void assertInterceptorFatalPropagates(Error failure, Error fatal, boolean batch) + throws Exception { + FailingInterceptor interceptor = new FailingInterceptor(2, failure); + TestableServlet servlet = newServlet(newServer(new ErrorServiceImpl(), ErrorService.class, + Collections.singletonList(interceptor))); + MockHttpServletResponse response = new MockHttpServletResponse(); + String request = request("test_ok", "[]", 2); + if (batch) { + request = "[" + request("test_ok", "[]", 1) + "," + request + "," + + request("test_ok", "[]", 3) + "]"; + } + String body = request; + + Assert.assertSame(fatal, Assert.assertThrows(Error.class, () -> post(servlet, body, response))); + + assertBareInternalServerError(response); + Assert.assertEquals(batch ? Arrays.asList(1, 2) : Collections.singletonList(2), + interceptor.dispatched); + } + + private static void assertSanitizedInternalError(JsonNode response, int id) { + JsonNode error = response.get("error"); + Assert.assertEquals(-32603, error.get("code").asInt()); + Assert.assertEquals("Internal error", error.get("message").asText()); + Assert.assertFalse(error.has("data")); + Assert.assertEquals(id, response.get("id").asInt()); + } + + private static void assertInvalidRequest(JsonNode response) { + Assert.assertTrue(response.isObject()); + Assert.assertEquals(MAPPER.getNodeFactory().textNode("2.0"), response.get("jsonrpc")); + JsonNode error = response.get("error"); + Assert.assertEquals(MAPPER.getNodeFactory().numberNode(-32600), error.get("code")); + Assert.assertEquals(MAPPER.getNodeFactory().textNode("Invalid Request"), + error.get("message")); + Assert.assertFalse(error.has("data")); + } + + private static void assertInvalidRequestResponse(MockHttpServletResponse response, + JsonNode expectedId) throws Exception { + Assert.assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + Assert.assertEquals("application/json-rpc", response.getContentType()); + JsonNode body = MAPPER.readTree(response.getContentAsByteArray()); + assertInvalidRequest(body); + Assert.assertEquals(expectedId, body.get("id")); + } + + private static void assertMappedInternalError(JsonNode response, int id) { + JsonNode error = response.get("error"); + Assert.assertEquals(-32000, error.get("code").asInt()); + Assert.assertEquals("Internal error", error.get("message").asText()); + Assert.assertEquals("{}", error.get("data").asText()); + Assert.assertEquals(id, response.get("id").asInt()); + } + + private static void assertChainIdentityError(JsonNode response, int id) { + JsonNode error = response.get("error"); + Assert.assertEquals(-32001, error.get("code").asInt()); + Assert.assertEquals("Chain identity unavailable", error.get("message").asText()); + Assert.assertEquals("{}", error.get("data").asText()); + Assert.assertEquals(id, response.get("id").asInt()); + } + + private static void assertNoInternalDetails(String response) { + Assert.assertFalse(response.contains(SENSITIVE_MARKER)); + Assert.assertFalse(response.contains("java.")); + Assert.assertFalse(response.contains("NullPointerException")); + Assert.assertFalse(response.contains("RuntimeException")); + } + + private static void assertBareInternalServerError(MockHttpServletResponse response) { + Assert.assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, response.getStatus()); + Assert.assertEquals(0, response.getContentAsByteArray().length); + Assert.assertTrue(response.isCommitted()); + } + + public interface ErrorService { + + @JsonRpcMethod("test_ok") + String ok(); + + @JsonRpcMethod("test_echo") + String echo(String value); + + @JsonRpcMethod("test_unhandled") + String unhandled(); + + @JsonRpcMethod("test_assertion") + String assertion(); + + @JsonRpcMethod("test_fatal") + String fatal(); + + @JsonRpcMethod("test_tron_error") + String tronError(); + } + + public static class ErrorServiceImpl implements ErrorService { + + private final StackOverflowError directFatal = + new StackOverflowError("direct-fatal-marker"); + private final TronError tronError = + new TronError("tron-fatal-marker", TronError.ErrCode.API_SERVER_INIT); + + @Override + public String ok() { + return "ok"; + } + + @Override + public String echo(String value) { + return value; + } + + @Override + public String unhandled() { + throw new RuntimeException(SENSITIVE_MARKER); + } + + @Override + public String assertion() { + throw new AssertionError(SENSITIVE_MARKER); + } + + @Override + public String fatal() { + throw directFatal; + } + + @Override + public String tronError() { + throw tronError; + } + } + + private static class FailingInterceptor implements JsonRpcInterceptor { + + private final int failureId; + private final Error failure; + private final List dispatched = new ArrayList<>(); + + private FailingInterceptor(int failureId, Error failure) { + this.failureId = failureId; + this.failure = failure; + } + + @Override + public void preHandleJson(JsonNode json) { + int id = json.get("id").intValue(); + dispatched.add(id); + if (id == failureId) { + throw failure; + } + } + + @Override + public void preHandle(Object target, Method method, List params) { + } + + @Override + public void postHandle(Object target, Method method, List params, JsonNode result) { + } + + @Override + public void postHandleJson(JsonNode json) { + } + } + + private static class TestableServlet extends JsonRpcServlet { + + void callDoPost(HttpServletRequest request, HttpServletResponse response) + throws IOException { + doPost(request, response); + } + } +} diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletJettyTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletJettyTest.java new file mode 100644 index 00000000000..0411d5ceac7 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletJettyTest.java @@ -0,0 +1,300 @@ +package org.tron.core.services.jsonrpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.googlecode.jsonrpc4j.JsonRpcMethod; +import com.googlecode.jsonrpc4j.JsonRpcServer; +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.SocketException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.EnumSet; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import javax.servlet.DispatcherType; +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.apache.http.ConnectionClosedException; +import org.apache.http.HttpEntity; +import org.apache.http.NoHttpResponseException; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; +import org.eclipse.jetty.servlet.FilterHolder; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.TestConstants; +import org.tron.common.application.HttpService; +import org.tron.common.utils.PublicMethod; +import org.tron.core.config.args.Args; +import org.tron.core.services.filter.HttpInterceptor; +import org.tron.core.services.http.RateLimiterServlet; +import org.tron.core.services.ratelimiter.RateLimiterContainer; + +public class JsonRpcServletJettyTest { + + private static final String FATAL_MARKER = "fatal-response-marker"; + private static final String OBSERVATION_HEADER = "X-Test-Observation"; + + @ClassRule + public static final TemporaryFolder TEMPORARY_FOLDER = new TemporaryFolder(); + + private TestJsonRpcHttpService httpService; + private CloseableHttpClient client; + private FatalServiceImpl fatalService; + private final Map> commitObservations = + new ConcurrentHashMap<>(); + + @Before + public void setUp() throws Exception { + Args.setParam(new String[]{"-d", TEMPORARY_FOLDER.newFolder().toString()}, + TestConstants.TEST_CONF); + + fatalService = new FatalServiceImpl(); + client = HttpClients.custom().disableAutomaticRetries() + .setDefaultRequestConfig(RequestConfig.custom() + .setConnectTimeout(5000).setSocketTimeout(5000).build()) + .build(); + } + + private URI startServer(boolean withFilter) throws Exception { + JsonRpcServer rpcServer = new JsonRpcServer(fatalService, FatalService.class); + rpcServer.setErrorResolver(JsonRpcErrorResolver.INSTANCE); + rpcServer.setShouldLogInvocationErrors(false); + + TestJsonRpcServlet servlet = new TestJsonRpcServlet(rpcServer); + Field containerField = RateLimiterServlet.class.getDeclaredField("container"); + containerField.setAccessible(true); + containerField.set(servlet, new RateLimiterContainer()); + + int port = PublicMethod.chooseRandomPort(); + httpService = new TestJsonRpcHttpService(port, servlet, withFilter, commitObservations); + httpService.start().get(10, TimeUnit.SECONDS); + return new URI(String.format("http://localhost:%d/jsonrpc", port)); + } + + @After + public void tearDown() throws Exception { + try { + if (client != null) { + client.close(); + } + } finally { + try { + if (httpService != null) { + httpService.stop(); + } + } finally { + Args.clearParam(); + } + } + } + + @Test + public void fatalErrorDoesNotReachJettyDefaultErrorPage() throws Exception { + URI endpoint = startServer(false); + HttpPost request = new HttpPost(endpoint); + request.setHeader("Accept", "application/json"); + request.setEntity(new StringEntity( + "{\"jsonrpc\":\"2.0\",\"method\":\"test_fatal\",\"params\":[],\"id\":1}", + ContentType.APPLICATION_JSON)); + + try (CloseableHttpResponse response = client.execute(request)) { + HttpEntity entity = response.getEntity(); + byte[] body = entity == null ? new byte[0] : EntityUtils.toByteArray(entity); + String text = new String(body, StandardCharsets.UTF_8); + + Assert.assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + response.getStatusLine().getStatusCode()); + Assert.assertEquals(0, body.length); + Assert.assertFalse(text.contains(FATAL_MARKER)); + Assert.assertFalse(text.contains(StackOverflowError.class.getName())); + } catch (NoHttpResponseException | ConnectionClosedException | SocketException expected) { + // A fatal Error may close the connection after the best-effort empty 500 is committed. + } + + Assert.assertTrue("the request must reach the JSON-RPC method", fatalService.invoked.get()); + } + + @Test + public void fatalErrorWithProductionFilterDoesNotReachJettyDefaultErrorPage() throws Exception { + URI endpoint = startServer(true); + String[] acceptTypes = {"application/json", "text/html", "text/plain"}; + for (String acceptType : acceptTypes) { + fatalService.invoked.set(false); + CompletableFuture committed = new CompletableFuture<>(); + commitObservations.put(acceptType, committed); + HttpPost request = new HttpPost(endpoint); + request.setHeader("Accept", acceptType); + request.setHeader("Connection", "close"); + request.setHeader(OBSERVATION_HEADER, acceptType); + request.setEntity(new StringEntity( + "{\"jsonrpc\":\"2.0\",\"method\":\"test_fatal\",\"params\":[],\"id\":1}", + ContentType.APPLICATION_JSON)); + + try (CloseableHttpResponse response = client.execute(request)) { + HttpEntity entity = response.getEntity(); + byte[] body = entity == null ? new byte[0] : EntityUtils.toByteArray(entity); + String text = new String(body, StandardCharsets.UTF_8); + + Assert.assertEquals(acceptType, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + response.getStatusLine().getStatusCode()); + Assert.assertEquals(acceptType, 0, body.length); + Assert.assertFalse(text.contains(FATAL_MARKER)); + Assert.assertFalse(text.contains(StackOverflowError.class.getName())); + } catch (NoHttpResponseException | ConnectionClosedException | SocketException expected) { + // Jetty can abort the connection after the guard commits. Require server-side evidence + // below instead of treating an arbitrary network failure as a successful cleanup. + } + Assert.assertTrue("the underlying response must be committed as an empty 500", + committed.get(5, TimeUnit.SECONDS)); + Assert.assertTrue("the request must reach the method for " + acceptType, + fatalService.invoked.get()); + } + } + + @Test + public void successfulRequestWithProductionFilterPreservesResponse() throws Exception { + HttpPost request = new HttpPost(startServer(true)); + request.setEntity(new StringEntity( + "{\"jsonrpc\":\"2.0\",\"method\":\"test_ok\",\"params\":[],\"id\":7}", + ContentType.APPLICATION_JSON)); + + try (CloseableHttpResponse response = client.execute(request)) { + Assert.assertEquals(200, response.getStatusLine().getStatusCode()); + Assert.assertEquals("application/json-rpc", + ContentType.get(response.getEntity()).getMimeType()); + ObjectMapper mapper = new ObjectMapper(); + JsonNode body = mapper.readTree(EntityUtils.toByteArray(response.getEntity())); + Assert.assertEquals( + mapper.readTree("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":7}"), body); + } + } + + public interface FatalService { + + @JsonRpcMethod("test_fatal") + String fatal(); + + @JsonRpcMethod("test_ok") + String ok(); + } + + private static class FatalServiceImpl implements FatalService { + + private final AtomicBoolean invoked = new AtomicBoolean(); + + @Override + public String fatal() { + invoked.set(true); + throw new StackOverflowError(FATAL_MARKER); + } + + @Override + public String ok() { + return "ok"; + } + } + + private static class TestJsonRpcServlet extends JsonRpcServlet { + + private final JsonRpcServer testRpcServer; + + TestJsonRpcServlet(JsonRpcServer testRpcServer) { + this.testRpcServer = testRpcServer; + } + + @Override + public void init(ServletConfig config) throws ServletException { + setRpcServer(testRpcServer); + } + } + + private static class TestJsonRpcHttpService extends HttpService { + + private final JsonRpcServlet servlet; + private final boolean withFilter; + private final Map> commitObservations; + + TestJsonRpcHttpService(int port, JsonRpcServlet servlet, boolean withFilter, + Map> commitObservations) { + this.port = port; + this.contextPath = "/"; + this.servlet = servlet; + this.withFilter = withFilter; + this.commitObservations = commitObservations; + } + + @Override + protected void addServlet(ServletContextHandler context) { + context.addServlet(new ServletHolder(servlet), "/jsonrpc"); + } + + @Override + protected void addFilter(ServletContextHandler context) { + if (withFilter) { + context.addFilter(new FilterHolder(new CommitObserver(commitObservations)), "/*", + EnumSet.of(DispatcherType.REQUEST)); + context.addFilter(new FilterHolder(new HttpInterceptor()), "/*", + EnumSet.of(DispatcherType.REQUEST)); + } + } + } + + private static class CommitObserver implements Filter { + + private final Map> observations; + + CommitObserver(Map> observations) { + this.observations = observations; + } + + @Override + public void init(FilterConfig config) { + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + try { + chain.doFilter(request, response); + } catch (Throwable failure) { + String key = ((HttpServletRequest) request).getHeader(OBSERVATION_HEADER); + CompletableFuture observation = key == null ? null : observations.get(key); + if (observation != null) { + HttpServletResponse actual = (HttpServletResponse) response; + observation.complete(actual.isCommitted() + && actual.getStatus() == HttpServletResponse.SC_INTERNAL_SERVER_ERROR + && "0".equals(actual.getHeader("Content-Length"))); + } + throw failure; + } + } + + @Override + public void destroy() { + } + } +} diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java index d6c843b5aea..3556695630b 100644 --- a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java +++ b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java @@ -4,29 +4,51 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.googlecode.jsonrpc4j.JsonRpcServer; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletResponseWrapper; import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.slf4j.LoggerFactory; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.tron.common.parameter.CommonParameter; import org.tron.core.Constant; +import org.tron.core.services.filter.CharResponseWrapper; public class JsonRpcServletTest { @@ -41,9 +63,7 @@ public class JsonRpcServletTest { public void setUp() throws Exception { servlet = new TestableServlet(); mockRpcServer = mock(JsonRpcServer.class); - Field f = JsonRpcServlet.class.getDeclaredField("rpcServer"); - f.setAccessible(true); - f.set(servlet, mockRpcServer); + servlet.setRpcServer(mockRpcServer); savedMaxBatchSize = CommonParameter.getInstance().jsonRpcMaxBatchSize; savedMaxResponseSize = CommonParameter.getInstance().jsonRpcMaxResponseSize; } @@ -120,6 +140,175 @@ public void emptyBatch_returnsInvalidRequest() throws Exception { assertTrue(body.get("id").isNull()); } + @Test + public void invalidRequestIdTypes_returnInvalidRequestWithoutDispatch() throws Exception { + String[] invalidIds = {"true", "{}", "[]"}; + + for (String id : invalidIds) { + MockHttpServletResponse resp = doPost( + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\"," + + "\"params\":[],\"id\":" + id + "}"); + assertEquals(200, resp.getStatus()); + assertEquals("application/json-rpc", resp.getContentType()); + assertInvalidRequestWithNullId(MAPPER.readTree(resp.getContentAsByteArray())); + } + + verifyNoInteractions(mockRpcServer); + } + + @Test + public void nullRequestId_isNotRejectedByServletValidation() throws Exception { + int[] callCount = {0}; + doAnswer(inv -> { + callCount[0]++; + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + + doPost("{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\"," + + "\"params\":[],\"id\":null}"); + + assertEquals("a null id is valid JSON-RPC input and must reach dispatch", 1, callCount[0]); + } + + @Test + public void structuredAndAbsentParams_reachRpcServer() throws Exception { + List dispatchedRequests = new ArrayList<>(); + doAnswer(inv -> { + dispatchedRequests.add(MAPPER.readTree((InputStream) inv.getArgument(0))); + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + + String[] requests = { + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"id\":1}", + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\"," + + "\"params\":null,\"id\":2}", + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\"," + + "\"params\":[],\"id\":3}", + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\"," + + "\"params\":{},\"id\":4}" + }; + for (String request : requests) { + doPost(request); + } + + assertEquals("all supported params shapes must reach jsonrpc4j", + requests.length, dispatchedRequests.size()); + for (int i = 0; i < requests.length; i++) { + assertEquals("forwarded request must be unchanged at index " + i, + MAPPER.readTree(requests[i]), dispatchedRequests.get(i)); + } + } + + @Test + public void singleScalarParams_isRejectedBeforeDispatch() throws Exception { + MockHttpServletResponse resp = doPost( + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\"," + + "\"params\":5,\"id\":42}"); + + assertEquals(200, resp.getStatus()); + assertEquals("application/json-rpc", resp.getContentType()); + JsonNode body = MAPPER.readTree(resp.getContentAsByteArray()); + assertEquals(MAPPER.getNodeFactory().textNode("2.0"), body.get("jsonrpc")); + assertEquals(MAPPER.getNodeFactory().numberNode(-32600), + body.get("error").get("code")); + assertEquals(MAPPER.getNodeFactory().textNode("Invalid Request"), + body.get("error").get("message")); + assertFalse(body.get("error").has("data")); + assertEquals(MAPPER.getNodeFactory().numberNode(42), body.get("id")); + verifyNoInteractions(mockRpcServer); + } + + @Test + public void batchInvalidRequestId_isIsolatedFromValidSiblings() throws Exception { + int[] callCount = {0}; + doAnswer(inv -> { + JsonNode request = MAPPER.readTree((InputStream) inv.getArgument(0)); + OutputStream out = inv.getArgument(1); + out.write(("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":" + + request.get("id") + "}").getBytes(StandardCharsets.UTF_8)); + callCount[0]++; + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + + MockHttpServletResponse resp = doPost("[" + + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"params\":[],\"id\":1}," + + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"params\":[],\"id\":true}," + + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"params\":[],\"id\":\"two\"}" + + "]"); + + assertEquals(200, resp.getStatus()); + JsonNode body = MAPPER.readTree(resp.getContentAsByteArray()); + assertTrue(body.isArray()); + assertEquals(3, body.size()); + assertEquals("ok", body.get(0).get("result").asText()); + assertEquals(1, body.get(0).get("id").asInt()); + assertInvalidRequestWithNullId(body.get(1)); + assertEquals("ok", body.get(2).get("result").asText()); + assertEquals("two", body.get(2).get("id").asText()); + assertEquals("only valid requests should reach jsonrpc4j", 2, callCount[0]); + } + + @Test + public void batchScalarParams_isIsolatedFromValidSibling() throws Exception { + int[] callCount = {0}; + JsonNode[] dispatchedRequest = {null}; + doAnswer(inv -> { + dispatchedRequest[0] = MAPPER.readTree((InputStream) inv.getArgument(0)); + OutputStream out = inv.getArgument(1); + out.write(("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":" + + dispatchedRequest[0].get("id") + "}").getBytes(StandardCharsets.UTF_8)); + callCount[0]++; + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + + MockHttpServletResponse resp = doPost("[" + + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"params\":5,\"id\":1}," + + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"params\":[],\"id\":2}" + + "]"); + + assertEquals(200, resp.getStatus()); + JsonNode body = MAPPER.readTree(resp.getContentAsByteArray()); + assertTrue(body.isArray()); + assertEquals(2, body.size()); + assertEquals(MAPPER.getNodeFactory().numberNode(-32600), + body.get(0).get("error").get("code")); + assertEquals(MAPPER.getNodeFactory().textNode("Invalid Request"), + body.get(0).get("error").get("message")); + assertFalse(body.get(0).get("error").has("data")); + assertEquals(MAPPER.getNodeFactory().numberNode(1), body.get(0).get("id")); + assertEquals(MAPPER.getNodeFactory().textNode("ok"), body.get(1).get("result")); + assertEquals(MAPPER.getNodeFactory().numberNode(2), body.get(1).get("id")); + assertEquals("only the valid sibling should reach jsonrpc4j", 1, callCount[0]); + assertEquals(MAPPER.getNodeFactory().numberNode(2), dispatchedRequest[0].get("id")); + assertTrue(dispatchedRequest[0].get("params").isArray()); + } + + @Test + public void batchScalarParamsWithoutId_returnsErrorAndDispatchesValidNotification() + throws Exception { + List dispatchedRequests = new ArrayList<>(); + doAnswer(inv -> { + dispatchedRequests.add(MAPPER.readTree((InputStream) inv.getArgument(0))); + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + + MockHttpServletResponse resp = doPost("[" + + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"params\":5}," + + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"params\":[]}" + + "]"); + + assertEquals(200, resp.getStatus()); + assertEquals("application/json-rpc", resp.getContentType()); + JsonNode body = MAPPER.readTree(resp.getContentAsByteArray()); + assertTrue(body.isArray()); + assertEquals(1, body.size()); + assertInvalidRequestWithNullId(body.get(0)); + assertEquals("only the valid notification should reach jsonrpc4j", + 1, dispatchedRequests.size()); + assertFalse(dispatchedRequests.get(0).has("id")); + assertTrue(dispatchedRequests.get(0).get("params").isArray()); + } + @Test public void batchLimitDisabled_largeBatchAllowed() throws Exception { CommonParameter.getInstance().jsonRpcMaxBatchSize = 0; @@ -142,28 +331,550 @@ public void batchLimitDisabled_largeBatchAllowed() throws Exception { assertEquals("", resp.getContentAsString()); } - // --- rpcServer.handle exceptions --- + // --- rpcServer.handleRequest exceptions --- @Test public void rpcServerThrowsRuntimeException_returnsInternalError() throws Exception { doThrow(new RuntimeException("server exploded")).when(mockRpcServer) - .handle(any(HttpServletRequest.class), any(HttpServletResponse.class)); + .handleRequest(any(InputStream.class), any(OutputStream.class)); MockHttpServletResponse resp = doPost("{\"method\":\"eth_blockNumber\",\"id\":42}"); assertEquals(200, resp.getStatus()); JsonNode body = MAPPER.readTree(resp.getContentAsString()); assertFalse(body.isArray()); assertEquals(-32603, body.get("error").get("code").asInt()); + assertEquals("Internal error", body.get("error").get("message").asText()); + assertEquals(42, body.get("id").asInt()); + } + + @Test + public void rpcServerThrowsIOException_returnsInternalError() throws Exception { + doThrow(new IOException("server exploded")).when(mockRpcServer) + .handleRequest(any(InputStream.class), any(OutputStream.class)); + + MockHttpServletResponse resp = doPost("{\"method\":\"eth_blockNumber\",\"id\":42}"); + + assertEquals(200, resp.getStatus()); + JsonNode body = MAPPER.readTree(resp.getContentAsByteArray()); + assertEquals(-32603, body.get("error").get("code").asInt()); + assertEquals("Internal error", body.get("error").get("message").asText()); + assertEquals(42, body.get("id").asInt()); + } + + @Test + public void notificationIOException_returnsEmptyResponse() throws Exception { + doThrow(new IOException("server exploded")).when(mockRpcServer) + .handleRequest(any(InputStream.class), any(OutputStream.class)); + + MockHttpServletResponse resp = doPost("{\"method\":\"eth_blockNumber\"}"); + + assertEquals(200, resp.getStatus()); + assertEquals("application/json-rpc", resp.getContentType()); + assertEquals(0, resp.getContentAsByteArray().length); + } + + @Test + public void singleAssertionError_discardsPartialOutputAndReturnsInternalError() throws Exception { + doAnswer(inv -> { + OutputStream output = inv.getArgument(1); + output.write("{\"result\":\"partial-sensitive-marker".getBytes(StandardCharsets.UTF_8)); + throw new AssertionError("assertion-sensitive-marker"); + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + + assertSingleInternalError(doPost("{\"id\":42}"), "42"); + assertSingleInternalError(doPost("{\"id\":\"request-1\"}"), "\"request-1\""); + } + + @Test + public void singleAssertionErrorWithoutId_keepsEmptyResponse() throws Exception { + doThrow(new AssertionError("assertion-sensitive-marker")).when(mockRpcServer) + .handleRequest(any(InputStream.class), any(OutputStream.class)); + + MockHttpServletResponse response = doPost("{\"method\":\"eth_blockNumber\"}"); + + assertEquals(200, response.getStatus()); + assertEquals("application/json-rpc", response.getContentType()); + assertEquals(0, response.getContentAsByteArray().length); + } + + @Test + public void singleAssertionErrorWithNullId_keepsErrorResponse() throws Exception { + doThrow(new AssertionError("assertion-sensitive-marker")).when(mockRpcServer) + .handleRequest(any(InputStream.class), any(OutputStream.class)); + + assertSingleInternalError(doPost("{\"method\":\"eth_blockNumber\",\"id\":null}"), "null"); + } + + @Test + public void singleWrappedAssertionError_propagatesFatalCauseWithoutLogging() throws Exception { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + try (BatchLogCapture logs = new BatchLogCapture()) { + assertWrappedFatalPropagates(new AssertionError("wrapper-marker", fatal), fatal, false); + assertTrue(logs.executionEvents().isEmpty()); + } + } + + @Test + public void batchWrappedAssertionError_stopsBeforeLaterDispatchWithoutLogging() throws Exception { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + try (BatchLogCapture logs = new BatchLogCapture()) { + assertWrappedFatalPropagates(new AssertionError("wrapper-marker", fatal), fatal, true); + assertTrue(logs.executionEvents().isEmpty()); + } } @Test public void batchRpcServerThrows_internalErrorIsArray() throws Exception { doThrow(new RuntimeException("boom")).when(mockRpcServer) .handleRequest(any(InputStream.class), any(OutputStream.class)); - MockHttpServletResponse resp = doPost("[{\"method\":\"eth_blockNumber\"}]"); + MockHttpServletResponse resp = doPost( + "[{\"method\":\"eth_blockNumber\",\"id\":\"request-1\"}]"); assertEquals(200, resp.getStatus()); JsonNode body = MAPPER.readTree(resp.getContentAsString()); assertTrue("batch internal error must be an array", body.isArray()); assertEquals(-32603, body.get(0).get("error").get("code").asInt()); + assertEquals("Internal error", body.get(0).get("error").get("message").asText()); + assertEquals("request-1", body.get(0).get("id").asText()); + } + + @Test + public void batchRpcServerThrowsIOException_internalErrorIsArray() throws Exception { + doThrow(new IOException("boom")).when(mockRpcServer) + .handleRequest(any(InputStream.class), any(OutputStream.class)); + + MockHttpServletResponse resp = doPost( + "[{\"method\":\"eth_blockNumber\",\"id\":\"request-1\"}]"); + + assertEquals(200, resp.getStatus()); + JsonNode body = MAPPER.readTree(resp.getContentAsByteArray()); + assertTrue(body.isArray()); + assertEquals(-32603, body.get(0).get("error").get("code").asInt()); + assertEquals("Internal error", body.get(0).get("error").get("message").asText()); + assertEquals("request-1", body.get(0).get("id").asText()); + } + + @Test + public void fatalError_commitsBare500AndRethrowsOriginal() throws Exception { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + doThrow(fatal).when(mockRpcServer) + .handleRequest(any(InputStream.class), any(OutputStream.class)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + StackOverflowError thrown = assertThrows(StackOverflowError.class, + () -> doPost("{\"method\":\"eth_blockNumber\",\"id\":1}", response)); + + assertSame(fatal, thrown); + assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, response.getStatus()); + assertEquals(0, response.getContentAsByteArray().length); + assertTrue(response.isCommitted()); + } + + @Test + public void singleWrappedRuntimeException_propagatesFatalCause() throws Exception { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + assertWrappedFatalPropagates(new RuntimeException("wrapper", fatal), fatal, false); + } + + @Test + public void singleWrappedIOException_propagatesFatalCause() throws Exception { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + assertWrappedFatalPropagates(new IOException("wrapper", fatal), fatal, false); + } + + @Test + public void batchWrappedRuntimeException_stopsBeforeLaterDispatch() throws Exception { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + assertWrappedFatalPropagates(new RuntimeException("wrapper", fatal), fatal, true); + } + + @Test + public void batchWrappedIOException_stopsBeforeLaterDispatch() throws Exception { + StackOverflowError fatal = new StackOverflowError("fatal-marker"); + assertWrappedFatalPropagates(new IOException("wrapper", fatal), fatal, true); + } + + @Test + public void cleanupIOException_doesNotReplaceOriginalFatalError() throws Exception { + HttpServletResponse response = mock(HttpServletResponse.class); + doThrow(new IOException("cleanup-io-marker")).when(response).flushBuffer(); + assertCleanupFailureDoesNotReplaceFatal(response); + verify(response).flushBuffer(); + } + + @Test + public void cleanupError_doesNotReplaceOriginalFatalError() throws Exception { + HttpServletResponse response = mock(HttpServletResponse.class); + doThrow(new OutOfMemoryError("cleanup-error-marker")).when(response).flushBuffer(); + assertCleanupFailureDoesNotReplaceFatal(response); + verify(response).flushBuffer(); + } + + @Test + public void fatalError_unwrapsNestedResponsesBeforeCommitting() throws Exception { + MockHttpServletResponse actual = new MockHttpServletResponse(); + actual.getOutputStream().write("partial-response-marker".getBytes(StandardCharsets.UTF_8)); + HttpServletResponse wrapped = new HttpServletResponseWrapper( + new CharResponseWrapper(new CharResponseWrapper(actual))); + + assertCleanupFailureDoesNotReplaceFatal(wrapped); + + assertEquals(500, actual.getStatus()); + assertEquals(0, actual.getContentAsByteArray().length); + assertTrue(actual.isCommitted()); + } + + @Test + public void fatalError_atWrapperDepthLimitStillCommits() throws Exception { + MockHttpServletResponse actual = new MockHttpServletResponse(); + HttpServletResponse wrapped = actual; + for (int i = 0; i < 16; i++) { + wrapped = new CharResponseWrapper(wrapped); + } + + assertCleanupFailureDoesNotReplaceFatal(wrapped); + + assertEquals(500, actual.getStatus()); + assertEquals(0, actual.getContentAsByteArray().length); + assertTrue(actual.isCommitted()); + } + + @Test(timeout = 5000) + public void fatalError_selfReferencingWrapperAbandonsCleanup() throws Exception { + TrackingResponseWrapper wrapper = new TrackingResponseWrapper(new MockHttpServletResponse()); + wrapper.setResponse(wrapper); + + assertCleanupFailureDoesNotReplaceFatal(wrapper); + + assertEquals("cleanup must not delegate into a self-reference", 0, wrapper.cleanupCalls); + } + + @Test(timeout = 5000) + public void fatalError_cyclicWrappersAbandonCleanup() throws Exception { + TrackingResponseWrapper first = new TrackingResponseWrapper(new MockHttpServletResponse()); + TrackingResponseWrapper second = new TrackingResponseWrapper(first); + first.setResponse(second); + + assertCleanupFailureDoesNotReplaceFatal(first); + + assertEquals("cleanup must not delegate into a cycle", 0, first.cleanupCalls); + assertEquals("cleanup must not delegate into a cycle", 0, second.cleanupCalls); + } + + @Test(timeout = 5000) + public void fatalError_excessiveWrapperDepthAbandonsCleanup() throws Exception { + MockHttpServletResponse actual = new MockHttpServletResponse(); + List wrappers = new ArrayList<>(); + HttpServletResponse wrapped = actual; + for (int i = 0; i < 17; i++) { + TrackingResponseWrapper wrapper = new TrackingResponseWrapper(wrapped); + wrappers.add(wrapper); + wrapped = wrapper; + } + + assertCleanupFailureDoesNotReplaceFatal(wrapped); + + for (TrackingResponseWrapper wrapper : wrappers) { + assertEquals("cleanup must not delegate beyond the depth limit", 0, wrapper.cleanupCalls); + } + assertFalse(actual.isCommitted()); + } + + @Test + public void unwrappingFailure_doesNotReplaceOriginalFatalError() throws Exception { + boolean[] attempted = {false}; + HttpServletResponseWrapper response = new HttpServletResponseWrapper( + new MockHttpServletResponse()) { + @Override + public ServletResponse getResponse() { + attempted[0] = true; + throw new IllegalStateException("unwrap-failure-marker"); + } + }; + + assertCleanupFailureDoesNotReplaceFatal(response); + + assertTrue("cleanup must attempt to unwrap the response", attempted[0]); + } + + @Test + public void fatalError_doesNotResetCommittedUnderlyingResponse() throws Exception { + MockHttpServletResponse actual = new MockHttpServletResponse(); + byte[] body = "already-committed".getBytes(StandardCharsets.UTF_8); + actual.getOutputStream().write(body); + actual.flushBuffer(); + + assertCleanupFailureDoesNotReplaceFatal(new CharResponseWrapper(actual)); + + assertEquals(200, actual.getStatus()); + assertArrayEquals(body, actual.getContentAsByteArray()); + assertTrue(actual.isCommitted()); + } + + @Test + public void batchMalformedRpcServerResponse_preservesRequestId() throws Exception { + doAnswer(inv -> { + OutputStream out = inv.getArgument(1); + out.write("not-json".getBytes(StandardCharsets.UTF_8)); + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + + MockHttpServletResponse resp = doPost( + "[{\"method\":\"eth_blockNumber\",\"id\":42}]"); + + JsonNode body = MAPPER.readTree(resp.getContentAsByteArray()); + assertTrue(body.isArray()); + assertEquals(-32603, body.get(0).get("error").get("code").asInt()); + assertEquals("Internal error", body.get(0).get("error").get("message").asText()); + assertEquals(42, body.get(0).get("id").asInt()); + } + + @Test + public void batchRuntimeException_preservesSiblingsAndContinues() throws Exception { + List dispatched = stubBatchWithMiddleFailure(new RuntimeException("failure-marker")); + + MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]"); + + assertRecoveredBatch(response, "2"); + assertEquals(MAPPER.readTree("[{\"id\":1},{\"id\":2},{\"id\":3}]"), + MAPPER.valueToTree(dispatched)); + } + + @Test + public void batchIOException_preservesSiblingsAndContinues() throws Exception { + List dispatched = stubBatchWithMiddleFailure(new IOException("failure-marker")); + + MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]"); + + assertRecoveredBatch(response, "2"); + assertEquals(MAPPER.readTree("[{\"id\":1},{\"id\":2},{\"id\":3}]"), + MAPPER.valueToTree(dispatched)); + } + + @Test + public void batchAssertionError_discardsPartialOutputAndPreservesSiblings() throws Exception { + List dispatched = stubBatchWithMiddleFailure( + new AssertionError("assertion-sensitive-marker"), true); + String request = "[{\"id\":1},{\"id\":2},{\"id\":3}]"; + + MockHttpServletResponse response = doPost(request); + + assertFalse(response.getContentAsString().contains("partial-sensitive-marker")); + assertFalse(response.getContentAsString().contains("assertion-sensitive-marker")); + assertRecoveredBatch(response, "2"); + assertEquals(recoveredBatch("2"), MAPPER.reader() + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readTree(response.getContentAsByteArray())); + assertEquals(MAPPER.readTree(request), MAPPER.valueToTree(dispatched)); + } + + @Test + public void batchAssertionErrorWithoutId_keepsErrorNodeAndContinues() throws Exception { + List dispatched = stubBatchWithMiddleFailure( + new AssertionError("assertion-sensitive-marker")); + String request = "[{\"id\":1},{\"method\":\"eth_blockNumber\"},{\"id\":3}]"; + + MockHttpServletResponse response = doPost(request); + + assertRecoveredBatch(response, "null"); + assertEquals(MAPPER.readTree(request), MAPPER.valueToTree(dispatched)); + } + + @Test + public void batchAssertionErrors_logOnlyFirstStackPerBatch() throws Exception { + List dispatched = stubBatchWithTwoFailures( + new AssertionError("first-sensitive-marker"), + new AssertionError("repeat-sensitive-marker")); + + try (BatchLogCapture logs = new BatchLogCapture()) { + for (int i = 0; i < 2; i++) { + assertBatchWithTwoRecoveredFailures( + doPost("[{\"id\":1},{\"id\":2},{\"id\":3},{\"id\":4}]")); + } + + assertEquals(Arrays.asList(1, 2, 3, 4, 1, 2, 3, 4), dispatched); + List events = logs.events(); + assertEquals(4, events.size()); + assertBoundedBatchLogs(events.subList(0, 2), AssertionError.class, AssertionError.class); + assertBoundedBatchLogs(events.subList(2, 4), AssertionError.class, AssertionError.class); + } + } + + @Test + public void batchMultipleFailures_logsOnlyFirstStackAndPreservesResponses() throws Exception { + List dispatched = stubBatchWithTwoFailures( + new IOException("first-sensitive-marker"), + new IllegalStateException("repeat-sensitive-marker")); + + try (BatchLogCapture logs = new BatchLogCapture()) { + MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3},{\"id\":4}]"); + + assertBatchWithTwoRecoveredFailures(response); + assertEquals(Arrays.asList(1, 2, 3, 4), dispatched); + assertBoundedBatchLogs(logs.events()); + } + } + + @Test + public void consecutiveBatches_eachLogTheirFirstFailure() throws Exception { + List dispatched = stubBatchWithTwoFailures( + new IOException("first-sensitive-marker"), + new IllegalStateException("repeat-sensitive-marker")); + + try (BatchLogCapture logs = new BatchLogCapture()) { + for (int i = 0; i < 2; i++) { + assertBatchWithTwoRecoveredFailures( + doPost("[{\"id\":1},{\"id\":2},{\"id\":3},{\"id\":4}]")); + } + + assertEquals(Arrays.asList(1, 2, 3, 4, 1, 2, 3, 4), dispatched); + List events = logs.events(); + assertEquals(4, events.size()); + assertBoundedBatchLogs(events.subList(0, 2)); + assertBoundedBatchLogs(events.subList(2, 4)); + } + } + + @Test + public void batchFatalAfterOrdinaryFailure_doesNotLogFatalOrContinue() throws Exception { + StackOverflowError fatal = new StackOverflowError("fatal-sensitive-marker"); + List dispatched = stubBatchWithTwoFailures( + new IOException("first-sensitive-marker"), new IOException("wrapped-fatal", fatal)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + try (BatchLogCapture logs = new BatchLogCapture()) { + assertSame(fatal, assertThrows(StackOverflowError.class, + () -> doPost("[{\"id\":1},{\"id\":2},{\"id\":3},{\"id\":4}]", response))); + + assertEquals(Arrays.asList(1, 2, 3), dispatched); + assertEquals(500, response.getStatus()); + assertEquals(0, response.getContentAsByteArray().length); + assertTrue(response.isCommitted()); + List events = logs.events(); + assertEquals(1, events.size()); + assertFirstBatchFailureLog(events.get(0)); + } + } + + @Test + public void batchMalformedResponse_preservesSiblingsAndContinues() throws Exception { + List dispatched = stubBatchWithMalformedMiddleResponse(); + + MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]"); + + assertRecoveredBatch(response, "2"); + assertEquals(Arrays.asList(1, 2, 3), dispatched); + } + + @Test + public void batchSerializationFailure_preservesSiblingsAndContinues() throws Exception { + int[] serializationAttempts = {0}; + ObjectNode failingRequest = new ObjectNode(MAPPER.getNodeFactory()) { + @Override + public void serialize(JsonGenerator generator, SerializerProvider provider) + throws IOException { + serializationAttempts[0]++; + throw new JsonProcessingException("serialization-marker") {}; + } + }; + failingRequest.put("id", 2); + ArrayNode requests = MAPPER.createArrayNode(); + requests.addObject().put("id", 1); + requests.add(failingRequest); + requests.addObject().put("id", 3); + List dispatched = stubBatchWithMalformedMiddleResponse(); + MockHttpServletResponse response = new MockHttpServletResponse(); + Method handleBatch = JsonRpcServlet.class.getDeclaredMethod("handleBatch", + HttpServletResponse.class, JsonNode.class, int.class); + handleBatch.setAccessible(true); + + handleBatch.invoke(servlet, response, requests, 0); + + assertRecoveredBatch(response, "2"); + assertEquals(Arrays.asList(1, 3), dispatched); + assertEquals(1, serializationAttempts[0]); + } + + @Test + public void batchFailureWithoutId_keepsErrorNodeAndContinues() throws Exception { + List dispatched = stubBatchWithMiddleFailure(new IOException("failure-marker")); + String request = "[{\"id\":1},{\"method\":\"eth_blockNumber\"},{\"id\":3}]"; + + MockHttpServletResponse response = doPost(request); + + assertRecoveredBatch(response, "null"); + assertEquals(MAPPER.readTree(request), MAPPER.valueToTree(dispatched)); + } + + @Test + public void batchRecoveredError_atExactLimitDoesNotOverflow() throws Exception { + List dispatched = stubBatchWithMiddleFailure(new IOException("failure-marker")); + int limit = MAPPER.writeValueAsBytes(recoveredBatch("2")).length; + CommonParameter.getInstance().jsonRpcMaxResponseSize = limit; + + MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]"); + + assertRecoveredBatch(response, "2"); + assertEquals(limit, response.getContentAsByteArray().length); + assertEquals(3, dispatched.size()); + } + + @Test + public void batchMalformedResponse_countsOnlyReplacementBytes() throws Exception { + List dispatched = stubBatchWithMalformedMiddleResponse(); + int limit = MAPPER.writeValueAsBytes(recoveredBatch("2")).length; + CommonParameter.getInstance().jsonRpcMaxResponseSize = limit; + + MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]"); + + assertRecoveredBatch(response, "2"); + assertEquals(limit, response.getContentAsByteArray().length); + assertEquals(Arrays.asList(1, 2, 3), dispatched); + } + + @Test + public void batchRecoveredError_consumesBudgetForLaterResponse() throws Exception { + List dispatched = stubBatchWithMiddleFailure(new IOException("failure-marker")); + CommonParameter.getInstance().jsonRpcMaxResponseSize = + MAPPER.writeValueAsBytes(recoveredBatch("2")).length - 1; + + MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]"); + + assertRecoveredErrorConsumesBudget(response); + assertEquals(MAPPER.readTree("[{\"id\":1},{\"id\":2},{\"id\":3}]"), + MAPPER.valueToTree(dispatched)); + } + + @Test + public void batchMalformedResponse_replacementConsumesBudgetForLaterResponse() throws Exception { + List dispatched = stubBatchWithMalformedMiddleResponse(); + CommonParameter.getInstance().jsonRpcMaxResponseSize = + MAPPER.writeValueAsBytes(recoveredBatch("2")).length - 1; + + MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]"); + + assertRecoveredErrorConsumesBudget(response); + assertEquals(Arrays.asList(1, 2, 3), dispatched); + } + + @Test + public void batchRecoveredError_overflowStopsLaterDispatch() throws Exception { + List dispatched = stubBatchWithMiddleFailure(new IOException("failure-marker")); + CommonParameter.getInstance().jsonRpcMaxResponseSize = limitBeforeSecondErrorFits(); + + MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]"); + + assertBatchErrorOverflow(response); + assertEquals(2, dispatched.size()); + } + + @Test + public void batchMalformedResponse_replacementCanTriggerOverflow() throws Exception { + List dispatched = stubBatchWithMalformedMiddleResponse(); + CommonParameter.getInstance().jsonRpcMaxResponseSize = limitBeforeSecondErrorFits(); + + MockHttpServletResponse response = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]"); + + assertBatchErrorOverflow(response); + assertEquals(Arrays.asList(1, 2), dispatched); } // --- response size limit --- @@ -173,10 +884,10 @@ public void responseTooLarge_returnsSingleErrorObject() throws Exception { int limit = 50; CommonParameter.getInstance().jsonRpcMaxResponseSize = limit; doAnswer(inv -> { - HttpServletResponse r = inv.getArgument(1); - r.getOutputStream().write(new byte[limit + 1]); - return null; - }).when(mockRpcServer).handle(any(HttpServletRequest.class), any(HttpServletResponse.class)); + OutputStream out = inv.getArgument(1); + out.write(new byte[limit + 1]); + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); MockHttpServletResponse resp = doPost("{\"method\":\"eth_getLogs\",\"id\":1}"); assertEquals(200, resp.getStatus()); @@ -233,16 +944,42 @@ public void batchShortCircuitsOnOverflow() throws Exception { assertEquals("third sub-request must not be executed after overflow", 2, callCount[0]); } + @Test + public void batchInvalidRequestId_afterOverflowStillReturnsInvalidRequest() throws Exception { + int limit = 50; + CommonParameter.getInstance().jsonRpcMaxResponseSize = limit; + int[] callCount = {0}; + doAnswer(inv -> { + OutputStream out = inv.getArgument(1); + out.write(new byte[limit + 1]); + callCount[0]++; + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + + MockHttpServletResponse resp = doPost("[" + + "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getLogs\",\"id\":1}," + + "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getLogs\",\"id\":{}}" + + "]"); + + JsonNode body = MAPPER.readTree(resp.getContentAsByteArray()); + assertTrue(body.isArray()); + assertEquals(2, body.size()); + assertEquals(-32003, body.get(0).get("error").get("code").asInt()); + assertEquals(1, body.get(0).get("id").asInt()); + assertInvalidRequestWithNullId(body.get(1)); + assertEquals("invalid requests must not be dispatched after overflow", 1, callCount[0]); + } + // --- normal path --- @Test public void normalRequest_commitsRpcServerResponse() throws Exception { byte[] rpcResp = "{\"result\":\"0x1\"}".getBytes(StandardCharsets.UTF_8); doAnswer(inv -> { - HttpServletResponse r = inv.getArgument(1); - r.getOutputStream().write(rpcResp); - return null; - }).when(mockRpcServer).handle(any(HttpServletRequest.class), any(HttpServletResponse.class)); + OutputStream out = inv.getArgument(1); + out.write(rpcResp); + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); MockHttpServletResponse resp = doPost("{\"method\":\"eth_blockNumber\",\"id\":1}"); assertEquals(200, resp.getStatus()); @@ -417,14 +1154,293 @@ public void tooManyTokens_returnsParseError() throws Exception { // --- helpers --- + private List stubBatchWithTwoFailures(Throwable first, Throwable second) + throws Exception { + List dispatched = new ArrayList<>(); + doAnswer(inv -> { + int id = MAPPER.readTree((InputStream) inv.getArgument(0)).get("id").asInt(); + dispatched.add(id); + if (id == 2) { + throw first; + } + if (id == 3) { + throw second; + } + OutputStream out = inv.getArgument(1); + out.write(successResponse(id)); + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + return dispatched; + } + + private static void assertBatchWithTwoRecoveredFailures(MockHttpServletResponse response) + throws IOException { + assertEquals(200, response.getStatus()); + assertEquals("application/json-rpc", response.getContentType()); + assertEquals(MAPPER.readTree("[{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":1}," + + "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal error\"}," + + "\"id\":2},{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603," + + "\"message\":\"Internal error\"},\"id\":3}," + + "{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":4}]"), + MAPPER.readTree(response.getContentAsByteArray())); + } + + private static void assertBoundedBatchLogs(List events) { + assertBoundedBatchLogs(events, IOException.class, IllegalStateException.class); + } + + private static void assertBoundedBatchLogs(List events, + Class firstType, Class repeatedType) { + assertEquals(2, events.size()); + assertFirstBatchFailureLog(events.get(0), firstType); + ILoggingEvent repeated = events.get(1); + assertEquals(Level.DEBUG, repeated.getLevel()); + assertNull(repeated.getThrowableProxy()); + assertArrayEquals(new Object[]{2, repeatedType.getName()}, + repeated.getArgumentArray()); + assertFalse(repeated.getFormattedMessage().contains("repeat-sensitive-marker")); + } + + private static void assertFirstBatchFailureLog(ILoggingEvent event) { + assertFirstBatchFailureLog(event, IOException.class); + } + + private static void assertFirstBatchFailureLog(ILoggingEvent event, + Class failureType) { + assertEquals(Level.ERROR, event.getLevel()); + assertEquals("RPC execution failed for batch sub-request 1", event.getFormattedMessage()); + assertEquals(failureType.getName(), event.getThrowableProxy().getClassName()); + assertEquals("first-sensitive-marker", event.getThrowableProxy().getMessage()); + assertTrue(event.getThrowableProxy().getStackTraceElementProxyArray().length > 0); + } + + private static class BatchLogCapture implements AutoCloseable { + private final Logger apiLogger = (Logger) LoggerFactory.getLogger("API"); + private final Level originalLevel = apiLogger.getLevel(); + private final ListAppender appender = new ListAppender<>(); + + private BatchLogCapture() { + appender.start(); + apiLogger.addAppender(appender); + apiLogger.setLevel(Level.DEBUG); + } + + private List events() { + return matchingEvents("RPC execution failed for batch sub-request "); + } + + private List executionEvents() { + return matchingEvents("RPC execution failed"); + } + + private List matchingEvents(String prefix) { + List events = new ArrayList<>(); + for (ILoggingEvent event : appender.list) { + if (event.getMessage().startsWith(prefix)) { + events.add(event); + } + } + return events; + } + + @Override + public void close() { + apiLogger.setLevel(originalLevel); + apiLogger.detachAppender(appender); + appender.stop(); + } + } + + private List stubBatchWithMiddleFailure(Throwable failure) throws Exception { + return stubBatchWithMiddleFailure(failure, false); + } + + private List stubBatchWithMiddleFailure(Throwable failure, boolean partialOutput) + throws Exception { + List dispatched = new ArrayList<>(); + doAnswer(inv -> { + JsonNode request = MAPPER.readTree((InputStream) inv.getArgument(0)); + dispatched.add(request); + if (dispatched.size() == 2) { + if (partialOutput) { + OutputStream output = inv.getArgument(1); + output.write("{\"result\":\"partial-sensitive-marker".getBytes(StandardCharsets.UTF_8)); + } + throw failure; + } + OutputStream out = inv.getArgument(1); + out.write(successResponse(request.get("id").asInt())); + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + return dispatched; + } + + private List stubBatchWithMalformedMiddleResponse() throws Exception { + List dispatched = new ArrayList<>(); + doAnswer(inv -> { + JsonNode request = MAPPER.readTree((InputStream) inv.getArgument(0)); + int id = request.get("id").asInt(); + dispatched.add(id); + OutputStream out = inv.getArgument(1); + out.write(id == 2 ? "not-json".getBytes(StandardCharsets.UTF_8) : successResponse(id)); + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + return dispatched; + } + + private static byte[] successResponse(int id) { + return ("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":" + id + "}") + .getBytes(StandardCharsets.UTF_8); + } + + private static JsonNode recoveredBatch(String middleId) throws IOException { + return MAPPER.readTree("[{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":1}," + + "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal error\"}," + + "\"id\":" + middleId + "},{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":3}]"); + } + + private static void assertRecoveredBatch(MockHttpServletResponse response, String middleId) + throws IOException { + assertEquals(200, response.getStatus()); + assertEquals("application/json-rpc", response.getContentType()); + assertEquals(recoveredBatch(middleId), MAPPER.readTree(response.getContentAsByteArray())); + } + + private static int limitBeforeSecondErrorFits() throws IOException { + ArrayNode firstTwo = MAPPER.createArrayNode(); + firstTwo.add(recoveredBatch("2").get(0)); + firstTwo.add(recoveredBatch("2").get(1)); + return MAPPER.writeValueAsBytes(firstTwo).length - 1; + } + + private static void assertRecoveredErrorConsumesBudget(MockHttpServletResponse response) + throws IOException { + assertEquals(200, response.getStatus()); + assertEquals("application/json-rpc", response.getContentType()); + ArrayNode expected = (ArrayNode) recoveredBatch("2"); + expected.set(2, MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32003," + + "\"message\":\"Response exceeds the limit of " + + CommonParameter.getInstance().getJsonRpcMaxResponseSize() + + " bytes\"},\"id\":3}")); + assertEquals(expected, MAPPER.readTree(response.getContentAsByteArray())); + } + + private static void assertBatchErrorOverflow(MockHttpServletResponse response) + throws IOException { + assertEquals(200, response.getStatus()); + assertEquals("application/json-rpc", response.getContentType()); + JsonNode body = MAPPER.readTree(response.getContentAsByteArray()); + assertEquals(3, body.size()); + assertEquals(recoveredBatch("2").get(0), body.get(0)); + for (int i = 1; i < body.size(); i++) { + assertEquals("2.0", body.get(i).get("jsonrpc").asText()); + assertEquals(-32003, body.get(i).get("error").get("code").asInt()); + assertEquals("Response exceeds the limit of " + + CommonParameter.getInstance().getJsonRpcMaxResponseSize() + " bytes", + body.get(i).get("error").get("message").asText()); + assertFalse(body.get(i).get("error").has("data")); + assertEquals(i + 1, body.get(i).get("id").asInt()); + } + } + + private static void assertInvalidRequestWithNullId(JsonNode response) { + assertEquals(MAPPER.getNodeFactory().textNode("2.0"), response.get("jsonrpc")); + assertEquals(MAPPER.getNodeFactory().numberNode(-32600), + response.get("error").get("code")); + assertEquals(MAPPER.getNodeFactory().textNode("Invalid Request"), + response.get("error").get("message")); + assertFalse(response.get("error").has("data")); + assertTrue(response.get("id").isNull()); + } + private MockHttpServletResponse doPost(String body) throws Exception { - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/jsonrpc"); - req.setContent(body.getBytes(StandardCharsets.UTF_8)); MockHttpServletResponse resp = new MockHttpServletResponse(); - servlet.callDoPost(req, resp); + doPost(body, resp); return resp; } + private void doPost(String body, HttpServletResponse response) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/jsonrpc"); + request.setContent(body.getBytes(StandardCharsets.UTF_8)); + servlet.callDoPost(request, response); + } + + private void assertCleanupFailureDoesNotReplaceFatal(HttpServletResponse response) + throws Exception { + StackOverflowError fatal = new StackOverflowError("original-fatal-marker"); + doThrow(fatal).when(mockRpcServer) + .handleRequest(any(InputStream.class), any(OutputStream.class)); + + StackOverflowError thrown = assertThrows(StackOverflowError.class, + () -> doPost("{\"method\":\"eth_blockNumber\",\"id\":1}", response)); + + assertSame(fatal, thrown); + } + + private void assertWrappedFatalPropagates(Throwable failure, Error fatal, boolean batch) + throws Exception { + List dispatched = new ArrayList<>(); + doAnswer(inv -> { + JsonNode request = MAPPER.readTree((InputStream) inv.getArgument(0)); + int id = request.get("id").asInt(); + dispatched.add(id); + if (id == (batch ? 2 : 1)) { + throw failure; + } + OutputStream out = inv.getArgument(1); + out.write(("{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":" + id + "}") + .getBytes(StandardCharsets.UTF_8)); + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + MockHttpServletResponse response = new MockHttpServletResponse(); + String request = batch ? "[{\"id\":1},{\"id\":2},{\"id\":3}]" : "{\"id\":1}"; + + Error thrown = assertThrows(Error.class, () -> doPost(request, response)); + + assertSame(fatal, thrown); + assertEquals(500, response.getStatus()); + assertEquals(0, response.getContentAsByteArray().length); + assertTrue(response.isCommitted()); + assertEquals(batch ? Arrays.asList(1, 2) : Arrays.asList(1), dispatched); + } + + private static void assertSingleInternalError(MockHttpServletResponse response, String id) + throws IOException { + assertEquals(200, response.getStatus()); + assertEquals("application/json-rpc", response.getContentType()); + assertFalse(response.getContentAsString().contains("partial-sensitive-marker")); + assertFalse(response.getContentAsString().contains("assertion-sensitive-marker")); + assertEquals(MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603," + + "\"message\":\"Internal error\"},\"id\":" + id + "}"), + MAPPER.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readTree(response.getContentAsByteArray())); + } + + private static class TrackingResponseWrapper extends HttpServletResponseWrapper { + + private int cleanupCalls; + + TrackingResponseWrapper(HttpServletResponse response) { + super(response); + } + + @Override + public boolean isCommitted() { + cleanupCalls++; + return true; + } + + @Override + public void resetBuffer() { + cleanupCalls++; + } + + @Override + public void flushBuffer() { + cleanupCalls++; + } + } + private static class TestableServlet extends JsonRpcServlet { void callDoPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/TronJsonRpcImplChainIdentityTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/TronJsonRpcImplChainIdentityTest.java new file mode 100644 index 00000000000..93ba96ace05 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/jsonrpc/TronJsonRpcImplChainIdentityTest.java @@ -0,0 +1,78 @@ +package org.tron.core.services.jsonrpc; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.Assert; +import org.junit.Test; +import org.slf4j.LoggerFactory; +import org.tron.core.Wallet; +import org.tron.core.capsule.BlockCapsule; +import org.tron.core.exception.jsonrpc.JsonRpcInternalException; + +public class TronJsonRpcImplChainIdentityTest { + + @Test + public void testChainIdentityLogsFailureStateTransitions() throws Exception { + Wallet wallet = mock(Wallet.class); + BlockCapsule genesis = mock(BlockCapsule.class); + when(genesis.getBlockId()).thenReturn(new BlockCapsule.BlockId(new byte[32], 0)); + when(wallet.getBlockCapsuleByNum(0)) + .thenReturn(null) + .thenReturn(null) + .thenReturn(genesis) + .thenReturn(null); + + TronJsonRpcImpl rpc = new TronJsonRpcImpl(null, wallet); + Logger apiLogger = (Logger) LoggerFactory.getLogger("API"); + ListAppender appender = new ListAppender<>(); + appender.start(); + apiLogger.addAppender(appender); + + try { + Assert.assertThrows(JsonRpcInternalException.class, rpc::ethChainId); + Assert.assertThrows(JsonRpcInternalException.class, rpc::ethChainId); + Assert.assertEquals("0x00000000", rpc.ethChainId()); + Assert.assertThrows(JsonRpcInternalException.class, rpc::ethChainId); + + Assert.assertEquals(2, + countEvents(appender, Level.WARN, "Chain identity lookup failed")); + Assert.assertEquals(1, + countEvents(appender, Level.INFO, "Chain identity lookup recovered")); + + ILoggingEvent firstFailure = findEvent( + appender, Level.WARN, "Chain identity lookup failed"); + Assert.assertNotNull(firstFailure); + Assert.assertNotNull(firstFailure.getThrowableProxy()); + } finally { + apiLogger.detachAppender(appender); + appender.stop(); + rpc.close(); + } + } + + private static ILoggingEvent findEvent(ListAppender appender, Level level, + String marker) { + for (ILoggingEvent event : appender.list) { + if (event.getLevel() == level && event.getFormattedMessage().contains(marker)) { + return event; + } + } + return null; + } + + private static int countEvents(ListAppender appender, Level level, + String marker) { + int count = 0; + for (ILoggingEvent event : appender.list) { + if (event.getLevel() == level && event.getFormattedMessage().contains(marker)) { + count++; + } + } + return count; + } +} diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/filters/LogBlockQueryFailureTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/filters/LogBlockQueryFailureTest.java new file mode 100644 index 00000000000..b377ca415d4 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/jsonrpc/filters/LogBlockQueryFailureTest.java @@ -0,0 +1,108 @@ +package org.tron.core.services.jsonrpc.filters; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.core.read.ListAppender; +import java.util.BitSet; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.ArgumentMatchers; +import org.slf4j.LoggerFactory; +import org.tron.core.services.jsonrpc.TronJsonRpc.FilterRequest; +import org.tron.core.store.SectionBloomStore; + +public class LogBlockQueryFailureTest { + + private static final long CURRENT_MAX_BLOCK_NUM = 100L; + private static final String SENSITIVE_MARKER = "worker-sensitive-marker"; + + @Test + public void testExecutionFailureLogsCauseAndRethrowsWrapper() throws Exception { + NullPointerException cause = new NullPointerException(SENSITIVE_MARKER); + ExecutionException failure = new ExecutionException(cause); + Future future = futureThrowing(failure); + LogBlockQuery query = newQuery(future); + ListAppender appender = attachApiAppender(); + + try { + ExecutionException thrown = Assert.assertThrows(ExecutionException.class, + query::getPossibleBlock); + + Assert.assertSame(failure, thrown); + Assert.assertTrue(hasThrowable(appender, NullPointerException.class, SENSITIVE_MARKER)); + } finally { + detachApiAppender(appender); + } + } + + @Test + public void testInterruptionRestoresFlagAndRethrows() throws Exception { + Thread.interrupted(); + InterruptedException failure = new InterruptedException(); + Future future = futureThrowing(failure); + LogBlockQuery query = newQuery(future); + ListAppender appender = attachApiAppender(); + + try { + InterruptedException thrown = Assert.assertThrows(InterruptedException.class, + query::getPossibleBlock); + + Assert.assertSame(failure, thrown); + Assert.assertTrue(Thread.currentThread().isInterrupted()); + Assert.assertTrue(hasThrowable(appender, InterruptedException.class, null)); + } finally { + Thread.interrupted(); + detachApiAppender(appender); + } + } + + private static LogBlockQuery newQuery(Future future) throws Exception { + ExecutorService executor = mock(ExecutorService.class); + when(executor.submit(ArgumentMatchers.>any())).thenReturn(future); + SectionBloomStore store = mock(SectionBloomStore.class); + LogFilterWrapper wrapper = new LogFilterWrapper( + new FilterRequest("0x0", "0x1", + "0x1111111111111111111111111111111111111111", null, null), + CURRENT_MAX_BLOCK_NUM, null, false); + return new LogBlockQuery(wrapper, store, CURRENT_MAX_BLOCK_NUM, executor); + } + + @SuppressWarnings("unchecked") + private static Future futureThrowing(Exception failure) throws Exception { + Future future = mock(Future.class); + when(future.get()).thenThrow(failure); + return future; + } + + private static ListAppender attachApiAppender() { + ListAppender appender = new ListAppender<>(); + appender.start(); + ((Logger) LoggerFactory.getLogger("API")).addAppender(appender); + return appender; + } + + private static void detachApiAppender(ListAppender appender) { + ((Logger) LoggerFactory.getLogger("API")).detachAppender(appender); + appender.stop(); + } + + private static boolean hasThrowable(ListAppender appender, + Class type, String message) { + for (ILoggingEvent event : appender.list) { + IThrowableProxy throwable = event.getThrowableProxy(); + if (throwable != null && type.getName().equals(throwable.getClassName()) + && (message == null || message.equals(throwable.getMessage()))) { + return true; + } + } + return false; + } +}