Skip to content

Commit bd32e36

Browse files
committed
fix(common): match Fastjson 1.x NaN/Infinity/NULL behavior
1 parent 669321a commit bd32e36

3 files changed

Lines changed: 174 additions & 12 deletions

File tree

common/src/main/java/org/tron/json/JSON.java

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010
import com.fasterxml.jackson.databind.ObjectMapper;
1111
import com.fasterxml.jackson.databind.SerializationFeature;
1212
import com.fasterxml.jackson.databind.json.JsonMapper;
13+
import com.fasterxml.jackson.databind.node.ArrayNode;
14+
import com.fasterxml.jackson.databind.node.NullNode;
1315
import com.fasterxml.jackson.databind.node.ObjectNode;
16+
import java.util.ArrayList;
17+
import java.util.List;
1418
import org.tron.common.parameter.CommonParameter;
1519

1620
/**
@@ -82,6 +86,113 @@ private static JsonFactory buildFactory() {
8286
private JSON() {
8387
}
8488

89+
/**
90+
* Fastjson 1.x parity: replace bare {@code NULL} tokens with {@code null}
91+
* in-place so Jackson's strict lowercase-only literal parser accepts them.
92+
* Skips contents of single- or double-quoted strings (with backslash-escape
93+
* support) and uses an identifier-aware boundary so unquoted field names
94+
* like {@code NULL_KEY} are left intact.
95+
*/
96+
static String coerceUppercaseNull(String text) {
97+
StringBuilder out = new StringBuilder(text.length());
98+
int i = 0;
99+
int n = text.length();
100+
while (i < n) {
101+
char c = text.charAt(i);
102+
if (c == '"' || c == '\'') {
103+
char quote = c;
104+
out.append(c);
105+
i++;
106+
while (i < n) {
107+
char ch = text.charAt(i);
108+
out.append(ch);
109+
i++;
110+
if (ch == '\\' && i < n) {
111+
out.append(text.charAt(i));
112+
i++;
113+
} else if (ch == quote) {
114+
break;
115+
}
116+
}
117+
continue;
118+
}
119+
if (c == 'N' && i + 4 <= n
120+
&& text.charAt(i + 1) == 'U'
121+
&& text.charAt(i + 2) == 'L'
122+
&& text.charAt(i + 3) == 'L'
123+
&& (i == 0 || !isIdentChar(text.charAt(i - 1)))
124+
&& (i + 4 == n || !isIdentChar(text.charAt(i + 4)))) {
125+
out.append("null");
126+
i += 4;
127+
continue;
128+
}
129+
out.append(c);
130+
i++;
131+
}
132+
return out.toString();
133+
}
134+
135+
private static boolean isIdentChar(char c) {
136+
return Character.isLetterOrDigit(c) || c == '_' || c == '$';
137+
}
138+
139+
/**
140+
* Fast pre-check for Fastjson 1.x non-numeric coercion. Because
141+
* {@code USE_BIG_DECIMAL_FOR_FLOATS} is on, the only way a {@code Double}
142+
* {@code NaN} / {@code Infinity} can land in the tree is via the literal
143+
* tokens {@code NaN} / {@code Infinity} in the source text — large numeric
144+
* literals go to BigDecimal/BigInteger without overflow. So a substring
145+
* absence proves the tree has no offending nodes, and the O(n) walk in
146+
* {@link #coerceNonNumeric} can be skipped on the common case.
147+
*/
148+
static boolean mayContainNonNumeric(String text) {
149+
return text != null && (text.contains("Infinity") || text.contains("NaN"));
150+
}
151+
152+
/**
153+
* Fastjson 1.x non-numeric-number parity: silently coerce {@code NaN} to JSON
154+
* {@code null}, reject {@code Infinity} / {@code -Infinity} with a
155+
* {@link JSONException} ({@code "syntax error, Infinity"} /
156+
* {@code "syntax error, -Infinity"}). Walks containers in-place.
157+
*/
158+
static JsonNode coerceNonNumeric(JsonNode node) {
159+
if (node == null || node.isNull()) {
160+
return node;
161+
}
162+
if (node.isFloatingPointNumber()) {
163+
double v = node.doubleValue();
164+
if (Double.isInfinite(v)) {
165+
throw new JSONException("syntax error, " + (v > 0 ? "Infinity" : "-Infinity"));
166+
}
167+
if (Double.isNaN(v)) {
168+
return NullNode.getInstance();
169+
}
170+
return node;
171+
}
172+
if (node.isObject()) {
173+
ObjectNode obj = (ObjectNode) node;
174+
List<String> keys = new ArrayList<>();
175+
obj.fieldNames().forEachRemaining(keys::add);
176+
for (String k : keys) {
177+
JsonNode child = obj.get(k);
178+
JsonNode replacement = coerceNonNumeric(child);
179+
if (replacement != child) {
180+
obj.set(k, replacement);
181+
}
182+
}
183+
} else if (node.isArray()) {
184+
ArrayNode arr = (ArrayNode) node;
185+
for (int i = 0; i < arr.size(); i++) {
186+
JsonNode child = arr.get(i);
187+
JsonNode replacement = coerceNonNumeric(child);
188+
if (replacement != child) {
189+
arr.set(i, replacement);
190+
}
191+
}
192+
}
193+
return node;
194+
}
195+
85196
/**
86197
* Returns {@code true} when {@code text} is null, blank, or a
87198
* case-insensitive {@code "null"} literal — mirroring Fastjson's lenient
@@ -99,8 +210,12 @@ public static JSONObject parseObject(String text) {
99210
if (isNullLiteral(text)) {
100211
return null;
101212
}
213+
String input = text.indexOf("NULL") >= 0 ? coerceUppercaseNull(text) : text;
102214
try {
103-
JsonNode node = MAPPER.readTree(text);
215+
JsonNode node = MAPPER.readTree(input);
216+
if (mayContainNonNumeric(input)) {
217+
node = coerceNonNumeric(node);
218+
}
104219
if (node == null || node.isNull()) {
105220
return null;
106221
}
@@ -136,12 +251,18 @@ public static JsonNode parse(String text) {
136251
if (isNullLiteral(text)) {
137252
return null;
138253
}
254+
String input = text.indexOf("NULL") >= 0 ? coerceUppercaseNull(text) : text;
139255
try {
140-
JsonNode node = MAPPER.readTree(text);
256+
JsonNode node = MAPPER.readTree(input);
257+
if (mayContainNonNumeric(input)) {
258+
node = coerceNonNumeric(node);
259+
}
141260
if (node == null || node.isNull()) {
142261
return null;
143262
}
144263
return node;
264+
} catch (JSONException e) {
265+
throw e;
145266
} catch (Exception e) {
146267
throw new JSONException(e.getMessage(), e);
147268
}

common/src/main/java/org/tron/json/JSONArray.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,12 @@ public static JSONArray parseArray(String text) {
3333
if (JSON.isNullLiteral(text)) {
3434
return null;
3535
}
36+
String input = text.indexOf("NULL") >= 0 ? JSON.coerceUppercaseNull(text) : text;
3637
try {
37-
JsonNode node = JSON.MAPPER.readTree(text);
38+
JsonNode node = JSON.MAPPER.readTree(input);
39+
if (JSON.mayContainNonNumeric(input)) {
40+
node = JSON.coerceNonNumeric(node);
41+
}
3842
if (node == null || node.isNull()) {
3943
return null;
4044
}

framework/src/test/java/org/tron/json/JsonTest.java

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,26 @@ public void testTrailingComma() {
4040

4141
@Test
4242
public void testNonNumericNumbers() {
43-
JSONObject json = JSON.parseObject("{a:NaN, b:Infinity, c:-Infinity}");
43+
JSONObject json = JSON.parseObject("{\"a\":NaN}");
4444
assertNotNull(json);
45-
double val = ((Number) json.get("a")).doubleValue();
46-
assertTrue(Double.isNaN(val)); // Fastjson is null, but jackson parses as NaN
47-
val = ((Number) json.get("b")).doubleValue();
48-
assertTrue(Double.isInfinite(val) && val > 0); // Fastjson will throw an error
49-
val = ((Number) json.get("c")).doubleValue();
50-
assertTrue(Double.isInfinite(val) && val < 0); // Fastjson will throw an error
45+
assertTrue(json.containsKey("a"));
46+
assertNull(json.get("a"));
47+
assertNull(json.getLong("a"));
48+
assertEquals(0, json.getIntValue("a"));
49+
50+
JSONArray arr = JSON.parseArray("[1, NaN, 2]");
51+
assertEquals(3, arr.size());
52+
assertNull(arr.get(1));
53+
JSONObject nested = JSON.parseObject("{outer:{inner:NaN}}");
54+
assertNull(nested.getJSONObject("outer").get("inner"));
55+
56+
JSONException eb = assertThrows(JSONException.class,
57+
() -> JSON.parseObject("{b:Infinity}"));
58+
assertEquals("syntax error, Infinity", eb.getMessage());
59+
JSONException ec = assertThrows(JSONException.class,
60+
() -> JSON.parseObject("{c:-Infinity}"));
61+
assertEquals("syntax error, -Infinity", ec.getMessage());
62+
assertThrows(JSONException.class, () -> JSON.parseArray("[Infinity]"));
5163
}
5264

5365
@Test
@@ -107,12 +119,37 @@ public void testThrows() {
107119
assertThrows(JSONException.class, () -> JSON.parseObject("{a:TRUE}"));
108120
assertThrows(JSONException.class, () -> JSON.parseObject("{a:FALSE}"));
109121
assertThrows(JSONException.class, () -> JSON.parseObject("[1,,3]"));
110-
// NOTE: Fastjson 1.x treats unquoted NULL as null, but jackson throws an error
111-
assertThrows(JSONException.class, () -> JSON.parseObject("{a:NULL}"));
112122
// valid JSON but wrong shape — exercises the single-arg JSONException constructor
113123
assertThrows(JSONException.class, () -> JSON.parseObject("[1,2,3]"));
114124
}
115125

126+
@Test
127+
public void testUppercaseNull() {
128+
// Fastjson 1.x parity: bare NULL token is treated as null.
129+
JSONObject obj = JSON.parseObject("{\"a\":NULL,\"b\":1}");
130+
assertNotNull(obj);
131+
assertTrue(obj.containsKey("a"));
132+
assertNull(obj.get("a"));
133+
assertEquals(1, obj.getIntValue("b"));
134+
135+
// Mixed in array, alongside lowercase null.
136+
JSONArray arr = JSON.parseArray("[NULL, null]");
137+
assertEquals(2, arr.size());
138+
assertNull(arr.get(0));
139+
assertNull(arr.get(1));
140+
141+
// String value containing the substring "NULL" must be preserved verbatim.
142+
JSONObject q = JSON.parseObject("{\"k\":\"NULL\"}");
143+
assertEquals("NULL", q.getString("k"));
144+
145+
// Unquoted identifier containing NULL as a prefix must NOT be touched.
146+
JSONObject id = JSON.parseObject("{NULL_KEY:1}");
147+
assertEquals(1, id.getIntValue("NULL_KEY"));
148+
149+
// Top-level standalone NULL — already handled by isNullLiteral.
150+
assertNull(JSON.parseObject("NULL"));
151+
}
152+
116153
@Test
117154
public void testParseToClass() {
118155
assertNotNull(JSON.parse("{\"a\":1}"));

0 commit comments

Comments
 (0)