Handle unescaped control chars in web3_clientVersion responses (#818)
* Fix node type detection for Tendermint nodes with unescaped control chars Some Tendermint EVM nodes (e.g. Moca) return a web3_clientVersion result like "Version dev ()\nCompiled at using Go go1.23.11 (amd64)" where the \n is a real LF (CTRL-CHAR, code 10), not a JSON escape. Jackson's default parser rejects this with "Illegal unquoted character", causing EthereumUpstreamSettingsDetector to fail node type detection. Parse the response leniently with ALLOW_UNQUOTED_CONTROL_CHARS at this single call site, and trim the version label to its first line so the resulting client_type/client_version labels stay clean. * Normalize whitespace uniformly instead of dropping post-newline content Previous fix took only the first line of the version string, which would drop the version token if a node placed it after the LF (only Moca happens to put it on the first line). Replace that with a uniform whitespace-collapsing helper applied in both code paths that extract a client version (mapping for node-type detection and parseClientVersion for client-version detection), so every token of the version string is preserved as a single-line, single-spaced label regardless of where it sits. * Revert version-string normalization, keep only ALLOW_UNQUOTED_CONTROL_CHARS The lenient JSON parser is sufficient on its own to fix the original parse error. Reverting the mapping/parseClientVersion changes leaves the existing slash/semver/dot logic untouched and lets the raw version string flow through as before. --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
package io.emeraldpay.dshackle.upstream
|
package io.emeraldpay.dshackle.upstream
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonParser
|
||||||
import com.fasterxml.jackson.databind.JsonNode
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
|
||||||
import io.emeraldpay.dshackle.Chain
|
import io.emeraldpay.dshackle.Chain
|
||||||
import io.emeraldpay.dshackle.Defaults.Companion.internalCallsTimeout
|
import io.emeraldpay.dshackle.Defaults.Companion.internalCallsTimeout
|
||||||
import io.emeraldpay.dshackle.Global
|
import io.emeraldpay.dshackle.Global
|
||||||
@@ -47,6 +47,21 @@ abstract class UpstreamSettingsDetector(
|
|||||||
protected abstract fun parseClientVersion(data: ByteArray): String
|
protected abstract fun parseClientVersion(data: ByteArray): String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the response of `web3_clientVersion`-like calls leniently. Some nodes
|
||||||
|
* (e.g. Moca's Tendermint EVM) return a JSON string that contains raw, unescaped
|
||||||
|
* control characters such as line feeds. Jackson rejects those by default with
|
||||||
|
* "Illegal unquoted character", so we enable ALLOW_UNQUOTED_CONTROL_CHARS for
|
||||||
|
* this single call site.
|
||||||
|
*/
|
||||||
|
internal fun parseLenientJson(data: ByteArray): JsonNode {
|
||||||
|
val factory = Global.objectMapper.factory
|
||||||
|
factory.createParser(data).use { parser ->
|
||||||
|
parser.enable(JsonParser.Feature.ALLOW_UNQUOTED_CONTROL_CHARS)
|
||||||
|
return Global.objectMapper.readTree(parser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
abstract class BasicUpstreamSettingsDetector(
|
abstract class BasicUpstreamSettingsDetector(
|
||||||
private val upstream: Upstream,
|
private val upstream: Upstream,
|
||||||
) : UpstreamSettingsDetector(upstream) {
|
) : UpstreamSettingsDetector(upstream) {
|
||||||
@@ -60,7 +75,7 @@ abstract class BasicUpstreamSettingsDetector(
|
|||||||
.getIngressReader()
|
.getIngressReader()
|
||||||
.read(nodeTypeRequest.request)
|
.read(nodeTypeRequest.request)
|
||||||
.flatMap(ChainResponse::requireResult)
|
.flatMap(ChainResponse::requireResult)
|
||||||
.map { Global.objectMapper.readValue<JsonNode>(it) }
|
.map { parseLenientJson(it) }
|
||||||
.flatMapMany { node ->
|
.flatMapMany { node ->
|
||||||
val labels = mutableListOf<Pair<String, String>>()
|
val labels = mutableListOf<Pair<String, String>>()
|
||||||
clientType(node)?.let {
|
clientType(node)?.let {
|
||||||
|
|||||||
@@ -239,4 +239,81 @@ class EthereumUpstreamSettingsDetectorSpec extends Specification {
|
|||||||
.expectComplete()
|
.expectComplete()
|
||||||
.verify(Duration.ofSeconds(1))
|
.verify(Duration.ofSeconds(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression: Moca Tendermint EVM returns a JSON string with raw, unescaped LFs:
|
||||||
|
// "Version dev ()\nCompiled at using Go go1.23.11 (amd64)"
|
||||||
|
// Jackson's default parser rejects this with "Illegal unquoted character (code 10)",
|
||||||
|
// which previously caused EthereumUpstreamSettingsDetector to fail node type detection.
|
||||||
|
def "Detect node type when client version contains unescaped control chars (Moca Tendermint)"() {
|
||||||
|
setup:
|
||||||
|
def rawVersion = "Version dev ()\nCompiled at using Go go1.23.11 (amd64)"
|
||||||
|
def jsonResultBytes = ('"' + rawVersion + '"').getBytes("UTF-8")
|
||||||
|
def up = Mock(DefaultUpstream) {
|
||||||
|
getId() >> "tiernet-us-east-bcn-05-moca-mainnet"
|
||||||
|
6 * getIngressReader() >> Mock(Reader) {
|
||||||
|
1 * read(new ChainRequest("web3_clientVersion", new ListParams())) >>
|
||||||
|
Mono.just(new ChainResponse(jsonResultBytes, null))
|
||||||
|
1 * read(new ChainRequest("eth_blockNumber", new ListParams())) >>
|
||||||
|
Mono.just(new ChainResponse("\"0x10df3e5\"".getBytes(), null))
|
||||||
|
1 * read(new ChainRequest("eth_getBalance", new ListParams(["0x0000000000000000000000000000000000000000", "0x10dccd5"]))) >>
|
||||||
|
Mono.error(new RuntimeException())
|
||||||
|
1 * read(new ChainRequest("eth_getBalance", new ListParams(["0x0000000000000000000000000000000000000000", "0x2710"]))) >>
|
||||||
|
Mono.just(new ChainResponse("".getBytes(), null))
|
||||||
|
1 * read(new ChainRequest("eth_call", new ListParams([
|
||||||
|
"to": "0x53Daa71B04d589429f6d3DF52db123913B818F22",
|
||||||
|
"data": "0x51be4eaa",
|
||||||
|
],
|
||||||
|
"latest",
|
||||||
|
[
|
||||||
|
"0x53Daa71B04d589429f6d3DF52db123913B818F22": [
|
||||||
|
"code": "0x6080604052348015600f57600080fd5b506004361060285760003560e01c806351be4eaa14602d575b600080fd5b60336047565b604051603e91906066565b60405180910390f35b60005a905090565b6000819050919050565b606081604f565b82525050565b6000602082019050607960008301846059565b9291505056fea26469706673582212201c0202887c1afe66974b06ee355dee07542bbc424cf4d1659c91f56c08c3dcc064736f6c63430008130033",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
))) >>
|
||||||
|
Mono.just(new ChainResponse("".getBytes(), null))
|
||||||
|
1 * read(new ChainRequest("eth_getBlockByNumber", new ListParams(["pending", false]))) >>
|
||||||
|
Mono.just(new ChainResponse("{}".getBytes(), null))
|
||||||
|
}
|
||||||
|
getLabels() >> []
|
||||||
|
}
|
||||||
|
def detector = new EthereumUpstreamSettingsDetector(up, Chain.ETHEREUM__MAINNET)
|
||||||
|
when:
|
||||||
|
def act = detector.internalDetectLabels()
|
||||||
|
then:
|
||||||
|
// The detector must not crash on the unescaped LF. With ALLOW_UNQUOTED_CONTROL_CHARS
|
||||||
|
// the JSON parses, and the resulting string runs through the existing
|
||||||
|
// slash/semver/dot logic: no slash, not semver-like, contains a dot ->
|
||||||
|
// client_type falls back to "default client" and client_version is the raw
|
||||||
|
// version string (preserved as-is, including the embedded LF).
|
||||||
|
StepVerifier.create(act)
|
||||||
|
.expectNext(new Pair<String, String>("client_type", "default client"))
|
||||||
|
.expectNext(new Pair<String, String>("client_version", rawVersion))
|
||||||
|
.expectNext(new Pair<String, String>("archive", "false"))
|
||||||
|
.expectNext(new Pair<String, String>("flashblocks", "false"))
|
||||||
|
.expectComplete()
|
||||||
|
.verify(Duration.ofSeconds(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
def "detectClientVersion handles unescaped control chars in version string"() {
|
||||||
|
setup:
|
||||||
|
def rawVersion = "Version dev ()\nCompiled at using Go go1.23.11 (amd64)"
|
||||||
|
def jsonResultBytes = ('"' + rawVersion + '"').getBytes("UTF-8")
|
||||||
|
def up = Mock(DefaultUpstream) {
|
||||||
|
2 * getIngressReader() >> Mock(Reader) {
|
||||||
|
1 * read(new ChainRequest("web3_clientVersion", new ListParams())) >>
|
||||||
|
Mono.just(new ChainResponse(jsonResultBytes, null))
|
||||||
|
}
|
||||||
|
1 * getLabels() >> List.of()
|
||||||
|
}
|
||||||
|
def detector = new EthereumUpstreamSettingsDetector(up, Chain.ETHEREUM__MAINNET)
|
||||||
|
when:
|
||||||
|
def act = detector.detectClientVersion()
|
||||||
|
then:
|
||||||
|
// parseClientVersion only strips the outer JSON quotes; the embedded LF is
|
||||||
|
// passed through unchanged. The important behavior is that it does not throw.
|
||||||
|
StepVerifier.create(act)
|
||||||
|
.expectNext(rawVersion)
|
||||||
|
.expectComplete()
|
||||||
|
.verify(Duration.ofSeconds(1))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package io.emeraldpay.dshackle.upstream
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class UpstreamSettingsDetectorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parseLenientJson accepts plain JSON string`() {
|
||||||
|
val data = "\"Geth/v1.12.0/linux-amd64/go1.20.3\"".toByteArray()
|
||||||
|
val node = parseLenientJson(data)
|
||||||
|
assertTrue(node.isTextual)
|
||||||
|
assertEquals("Geth/v1.12.0/linux-amd64/go1.20.3", node.asText())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parseLenientJson accepts JSON string with unescaped LF (Moca Tendermint)`() {
|
||||||
|
// Real-world Moca Tendermint web3_clientVersion result:
|
||||||
|
// "Version dev ()\nCompiled at using Go go1.23.11 (amd64)"
|
||||||
|
// where \n is a real line feed (CTRL-CHAR, code 10), not an escape sequence.
|
||||||
|
// Default Jackson rejects this with "Illegal unquoted character"; the lenient
|
||||||
|
// parser must accept it.
|
||||||
|
val raw = "Version dev ()\nCompiled at using Go go1.23.11 (amd64)"
|
||||||
|
val data = ("\"" + raw + "\"").toByteArray()
|
||||||
|
|
||||||
|
val node = parseLenientJson(data)
|
||||||
|
|
||||||
|
assertTrue(node.isTextual)
|
||||||
|
assertEquals(raw, node.asText())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parseLenientJson accepts JSON string with unescaped CR and tab`() {
|
||||||
|
val raw = "Version dev ()\r\n\tCompiled with Go go1.23.11"
|
||||||
|
val data = ("\"" + raw + "\"").toByteArray()
|
||||||
|
|
||||||
|
val node = parseLenientJson(data)
|
||||||
|
|
||||||
|
assertTrue(node.isTextual)
|
||||||
|
assertEquals(raw, node.asText())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parseLenientJson still parses normal JSON objects`() {
|
||||||
|
val data = """{"foo":"bar","n":42}""".toByteArray()
|
||||||
|
val node = parseLenientJson(data)
|
||||||
|
assertEquals("bar", node.get("foo").asText())
|
||||||
|
assertEquals(42, node.get("n").asInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user