Skip to content

Commit 6c46fdd

Browse files
authored
x.json2: add a strict: true mode for x.json2.decode(), that rejects string-to-number type casting during decoding (fix #26082) (#26220)
1 parent e1e6ddc commit 6c46fdd

3 files changed

Lines changed: 167 additions & 42 deletions

File tree

‎vlib/x/json2/decode.v‎

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,21 @@ mut:
4141
is_decoded bool
4242
}
4343

44-
// Decoder represents a JSON decoder.
44+
// DecoderOptions provides options for JSON decoding.
45+
// By default, decoding is lenient. Use `strict: true` for strict JSON spec compliance.
46+
@[params]
47+
pub struct DecoderOptions {
48+
pub:
49+
// In strict mode, quoted strings are not accepted as numbers.
50+
// For example, '"123"' will fail to decode as int in strict mode,
51+
// but will succeed in default mode.
52+
strict bool
53+
}
54+
55+
// Decoder is the internal decoding state.
4556
struct Decoder {
46-
json string // json is the JSON data to be decoded.
57+
json string // json is the JSON data to be decoded.
58+
strict bool // strict mode rejects quoted strings as numbers
4759
mut:
4860
values_info LinkedList[ValueInfo] // A linked list to store ValueInfo.
4961
checker_idx int // checker_idx is the current index of the decoder.
@@ -271,8 +283,9 @@ fn (mut decoder Decoder) decode_error(message string) ! {
271283
}
272284

273285
// decode decodes a JSON string into a specified type.
286+
// By default, decoding is lenient. Use `strict: true` for strict JSON spec compliance.
274287
@[manualfree]
275-
pub fn decode[T](val string) !T {
288+
pub fn decode[T](val string, params DecoderOptions) !T {
276289
if val == '' {
277290
return JsonDecodeError{
278291
message: 'empty string'
@@ -281,7 +294,8 @@ pub fn decode[T](val string) !T {
281294
}
282295
}
283296
mut decoder := Decoder{
284-
json: val
297+
json: val
298+
strict: params.strict
285299
}
286300

287301
decoder.check_json_format()!
@@ -627,12 +641,9 @@ fn (mut decoder Decoder) decode_value[T](mut val T) ! {
627641

628642
if value_info.value_kind == .number {
629643
unsafe { decoder.decode_number(&val)! }
630-
} else if value_info.value_kind == .string {
631-
// recheck if string contains number
632-
decoder.checker_idx = value_info.position + 1
633-
decoder.check_number()!
634-
635-
unsafe { decoder.decode_number(&val)! }
644+
} else if value_info.value_kind == .string && !decoder.strict {
645+
// In default mode, try to parse quoted strings as numbers
646+
val = decoder.decode_number_from_string[T]()!
636647
} else {
637648
decoder.decode_error('Expected number, but got ${value_info.value_kind}')!
638649
}
@@ -856,15 +867,7 @@ fn (mut decoder Decoder) decode_enum[T](mut val T) ! {
856867
// use pointer instead of mut so enum cast works
857868
@[unsafe]
858869
fn (mut decoder Decoder) decode_number[T](val &T) ! {
859-
mut number_info := decoder.current_node.value
860-
861-
if decoder.json[number_info.position] == `"` { // fake number
862-
number_info = ValueInfo{
863-
position: number_info.position + 1
864-
length: number_info.length - 2
865-
}
866-
}
867-
870+
number_info := decoder.current_node.value
868871
str := decoder.json[number_info.position..number_info.position + number_info.length]
869872
$match T.unaliased_typ {
870873
i8 { *val = strconv.atoi8(str)! }
@@ -883,3 +886,43 @@ fn (mut decoder Decoder) decode_number[T](val &T) ! {
883886
$else { return error('`decode_number` can not decode ${T.name} type') }
884887
}
885888
}
889+
890+
// decode_number_from_string parses a number from a JSON string value (default mode).
891+
// This extracts the content between quotes and parses it as a number.
892+
fn (mut decoder Decoder) decode_number_from_string[T]() !T {
893+
string_info := decoder.current_node.value
894+
// Extract string content without quotes (position+1 to skip opening quote, length-2 to exclude both quotes)
895+
if string_info.length < 2 {
896+
return error('invalid string for number conversion')
897+
}
898+
str := decoder.json[string_info.position + 1..string_info.position + string_info.length - 1]
899+
$if T.unaliased_typ is i8 {
900+
return T(strconv.atoi8(str)!)
901+
} $else $if T.unaliased_typ is i16 {
902+
return T(strconv.atoi16(str)!)
903+
} $else $if T.unaliased_typ is i32 {
904+
return T(strconv.atoi32(str)!)
905+
} $else $if T.unaliased_typ is i64 {
906+
return T(strconv.atoi64(str)!)
907+
} $else $if T.unaliased_typ is u8 {
908+
return T(strconv.atou8(str)!)
909+
} $else $if T.unaliased_typ is u16 {
910+
return T(strconv.atou16(str)!)
911+
} $else $if T.unaliased_typ is u32 {
912+
return T(strconv.atou32(str)!)
913+
} $else $if T.unaliased_typ is u64 {
914+
return T(strconv.atou64(str)!)
915+
} $else $if T.unaliased_typ is int {
916+
return T(strconv.atoi(str)!)
917+
} $else $if T.unaliased_typ is isize {
918+
return T(isize(strconv.atoi64(str)!))
919+
} $else $if T.unaliased_typ is usize {
920+
return T(usize(strconv.atou64(str)!))
921+
} $else $if T.unaliased_typ is f32 {
922+
return T(f32(strconv.atof_quick(str)))
923+
} $else $if T.unaliased_typ is f64 {
924+
return T(strconv.atof_quick(str))
925+
} $else {
926+
return error('`decode_number_from_string` cannot decode ${T.name} type')
927+
}
928+
}
Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,97 @@
11
import x.json2 as json
22

3-
fn test_budget_number() {
3+
// Tests for strict mode: quoted strings are NOT decoded as numbers (JSON spec compliance).
4+
// Tests for default mode: quoted strings ARE accepted as numbers for compatibility.
5+
6+
// ===== STRICT MODE TESTS =====
7+
8+
fn test_strict_string_not_decoded_as_int() {
9+
json.decode[int]('"0"', strict: true) or {
10+
assert err is json.JsonDecodeError
11+
if err is json.JsonDecodeError {
12+
assert err.message == 'Data: Expected number, but got string'
13+
}
14+
return
15+
}
16+
assert false, 'Expected JsonDecodeError for quoted number string in strict mode'
17+
}
18+
19+
fn test_strict_string_not_decoded_as_int_positive() {
20+
json.decode[int]('"100"', strict: true) or {
21+
assert err is json.JsonDecodeError
22+
if err is json.JsonDecodeError {
23+
assert err.message == 'Data: Expected number, but got string'
24+
}
25+
return
26+
}
27+
assert false, 'Expected JsonDecodeError for quoted number string in strict mode'
28+
}
29+
30+
fn test_strict_string_not_decoded_as_float() {
31+
json.decode[f64]('"-23.6e1"', strict: true) or {
32+
assert err is json.JsonDecodeError
33+
if err is json.JsonDecodeError {
34+
assert err.message == 'Data: Expected number, but got string'
35+
}
36+
return
37+
}
38+
assert false, 'Expected JsonDecodeError for quoted number string in strict mode'
39+
}
40+
41+
fn test_strict_string_in_array_not_decoded_as_int() {
42+
json.decode[[]int]('["100", 99, "98", 97]', strict: true) or {
43+
assert err is json.JsonDecodeError
44+
if err is json.JsonDecodeError {
45+
assert err.message == 'Data: Expected number, but got string'
46+
}
47+
return
48+
}
49+
assert false, 'Expected JsonDecodeError for quoted number string in array in strict mode'
50+
}
51+
52+
// ===== DEFAULT MODE TESTS =====
53+
54+
fn test_default_string_decoded_as_int() {
455
assert json.decode[int]('"0"')! == 0
556
assert json.decode[int]('"100"')! == 100
57+
assert json.decode[int]('"-50"')! == -50
58+
}
59+
60+
fn test_default_string_decoded_as_float() {
661
assert json.decode[f64]('"-23.6e1"')! == -236.0
62+
assert json.decode[f64]('"3.14"')! == 3.14
63+
}
764

65+
fn test_default_string_in_array_decoded_as_int() {
66+
assert json.decode[[]int]('["100", "99", "98", "97"]')! == [100, 99, 98, 97]
867
assert json.decode[[]int]('["100", 99, "98", 97]')! == [100, 99, 98, 97]
968
}
1069

11-
fn test_budget_number_malformed() {
12-
json.decode[int]('"+100"') or {
13-
if err is json.JsonDecodeError {
14-
assert err.line == 1
15-
assert err.character == 2
16-
assert err.message == 'Syntax: expected digit got +'
17-
}
70+
// ===== COMMON TESTS (both modes) =====
71+
72+
fn test_valid_unquoted_numbers() {
73+
assert json.decode[int]('0')! == 0
74+
assert json.decode[int]('100')! == 100
75+
assert json.decode[int]('-50')! == -50
76+
assert json.decode[f64]('-23.6e1')! == -236.0
77+
assert json.decode[[]int]('[100, 99, 98, 97]')! == [100, 99, 98, 97]
78+
79+
// Strict mode also works for unquoted numbers
80+
assert json.decode[int]('0', strict: true)! == 0
81+
assert json.decode[int]('100', strict: true)! == 100
82+
}
1883

84+
fn test_invalid_number_string_fails() {
85+
json.decode[int]('"not_a_number"') or { return }
86+
assert false, 'Expected error for invalid number string'
87+
}
88+
89+
fn test_leading_plus_in_string() {
90+
assert json.decode[int]('"+100"')! == 100
91+
92+
json.decode[int]('"+100"', strict: true) or {
93+
assert err is json.JsonDecodeError
1994
return
2095
}
21-
22-
assert false
96+
assert false, 'Expected error in strict mode'
2397
}

‎vlib/x/json2/tests/decode_struct_test.v‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -166,22 +166,30 @@ fn test_number_boundaries() {
166166
println('✓ Boundary values test passed')
167167
}
168168

169-
fn test_fake_numbers() {
170-
// Test quoted numbers (fake numbers)
171-
fake_number_cases := [
172-
'{"val1": "123", "val2": "45"}', // Fully quoted
173-
'{"val1": 123, "val2": "45"}', // Mixed format
174-
'{"val1": "255", "val2": 0}', // Boundary value as string
169+
fn test_quoted_numbers_in_strict_mode() {
170+
quoted_number_cases := [
171+
'{"val1": "123", "val2": "45"}',
172+
'{"val1": 123, "val2": "45"}',
173+
'{"val1": "255", "val2": 0}',
175174
]!
176175

177-
for case in fake_number_cases {
178-
result := json.decode[JsonU8](case) or {
179-
panic('Fake number decoding failed: ${err}, input: ${case}')
180-
}
181-
assert result.val1 >= 0 && result.val1 <= 255
182-
assert result.val2 >= 0 && result.val2 <= 255
176+
for case in quoted_number_cases {
177+
json.decode[JsonU8](case, strict: true) or { continue }
178+
panic('Expected decoding to fail for quoted number in strict mode but succeeded: ${case}')
179+
}
180+
println('✓ Quoted numbers correctly rejected in strict mode test passed')
181+
}
182+
183+
fn test_quoted_numbers_in_default_mode() {
184+
assert json.decode[JsonU8]('{"val1": "123", "val2": "45"}')! == JsonU8{
185+
val1: 123
186+
val2: 45
187+
}
188+
assert json.decode[JsonU8]('{"val1": 123, "val2": "45"}')! == JsonU8{
189+
val1: 123
190+
val2: 45
183191
}
184-
println('✓ Fake numbers (quoted numbers) test passed')
192+
println('✓ Quoted numbers accepted in default mode test passed')
185193
}
186194

187195
fn test_error_conditions() {

0 commit comments

Comments
 (0)