From 6c46fdd6a72b46f10ec3d15f72c3c9153c1c4ff5 Mon Sep 17 00:00:00 2001 From: Felix Ehlers Date: Thu, 1 Jan 2026 14:34:26 +0100 Subject: [PATCH] x.json2: add a `strict: true` mode for x.json2.decode(), that rejects string-to-number type casting during decoding (fix #26082) (#26220) --- vlib/x/json2/decode.v | 81 ++++++++++++---- .../x/json2/tests/decode_budget_number_test.v | 94 +++++++++++++++++-- vlib/x/json2/tests/decode_struct_test.v | 34 ++++--- 3 files changed, 167 insertions(+), 42 deletions(-) diff --git a/vlib/x/json2/decode.v b/vlib/x/json2/decode.v index c88245d76..87d9023b4 100644 --- a/vlib/x/json2/decode.v +++ b/vlib/x/json2/decode.v @@ -41,9 +41,21 @@ mut: is_decoded bool } -// Decoder represents a JSON decoder. +// DecoderOptions provides options for JSON decoding. +// By default, decoding is lenient. Use `strict: true` for strict JSON spec compliance. +@[params] +pub struct DecoderOptions { +pub: + // In strict mode, quoted strings are not accepted as numbers. + // For example, '"123"' will fail to decode as int in strict mode, + // but will succeed in default mode. + strict bool +} + +// Decoder is the internal decoding state. struct Decoder { - json string // json is the JSON data to be decoded. + json string // json is the JSON data to be decoded. + strict bool // strict mode rejects quoted strings as numbers mut: values_info LinkedList[ValueInfo] // A linked list to store ValueInfo. checker_idx int // checker_idx is the current index of the decoder. @@ -271,8 +283,9 @@ fn (mut decoder Decoder) decode_error(message string) ! { } // decode decodes a JSON string into a specified type. +// By default, decoding is lenient. Use `strict: true` for strict JSON spec compliance. @[manualfree] -pub fn decode[T](val string) !T { +pub fn decode[T](val string, params DecoderOptions) !T { if val == '' { return JsonDecodeError{ message: 'empty string' @@ -281,7 +294,8 @@ pub fn decode[T](val string) !T { } } mut decoder := Decoder{ - json: val + json: val + strict: params.strict } decoder.check_json_format()! @@ -627,12 +641,9 @@ fn (mut decoder Decoder) decode_value[T](mut val T) ! { if value_info.value_kind == .number { unsafe { decoder.decode_number(&val)! } - } else if value_info.value_kind == .string { - // recheck if string contains number - decoder.checker_idx = value_info.position + 1 - decoder.check_number()! - - unsafe { decoder.decode_number(&val)! } + } else if value_info.value_kind == .string && !decoder.strict { + // In default mode, try to parse quoted strings as numbers + val = decoder.decode_number_from_string[T]()! } else { decoder.decode_error('Expected number, but got ${value_info.value_kind}')! } @@ -856,15 +867,7 @@ fn (mut decoder Decoder) decode_enum[T](mut val T) ! { // use pointer instead of mut so enum cast works @[unsafe] fn (mut decoder Decoder) decode_number[T](val &T) ! { - mut number_info := decoder.current_node.value - - if decoder.json[number_info.position] == `"` { // fake number - number_info = ValueInfo{ - position: number_info.position + 1 - length: number_info.length - 2 - } - } - + number_info := decoder.current_node.value str := decoder.json[number_info.position..number_info.position + number_info.length] $match T.unaliased_typ { i8 { *val = strconv.atoi8(str)! } @@ -883,3 +886,43 @@ fn (mut decoder Decoder) decode_number[T](val &T) ! { $else { return error('`decode_number` can not decode ${T.name} type') } } } + +// decode_number_from_string parses a number from a JSON string value (default mode). +// This extracts the content between quotes and parses it as a number. +fn (mut decoder Decoder) decode_number_from_string[T]() !T { + string_info := decoder.current_node.value + // Extract string content without quotes (position+1 to skip opening quote, length-2 to exclude both quotes) + if string_info.length < 2 { + return error('invalid string for number conversion') + } + str := decoder.json[string_info.position + 1..string_info.position + string_info.length - 1] + $if T.unaliased_typ is i8 { + return T(strconv.atoi8(str)!) + } $else $if T.unaliased_typ is i16 { + return T(strconv.atoi16(str)!) + } $else $if T.unaliased_typ is i32 { + return T(strconv.atoi32(str)!) + } $else $if T.unaliased_typ is i64 { + return T(strconv.atoi64(str)!) + } $else $if T.unaliased_typ is u8 { + return T(strconv.atou8(str)!) + } $else $if T.unaliased_typ is u16 { + return T(strconv.atou16(str)!) + } $else $if T.unaliased_typ is u32 { + return T(strconv.atou32(str)!) + } $else $if T.unaliased_typ is u64 { + return T(strconv.atou64(str)!) + } $else $if T.unaliased_typ is int { + return T(strconv.atoi(str)!) + } $else $if T.unaliased_typ is isize { + return T(isize(strconv.atoi64(str)!)) + } $else $if T.unaliased_typ is usize { + return T(usize(strconv.atou64(str)!)) + } $else $if T.unaliased_typ is f32 { + return T(f32(strconv.atof_quick(str))) + } $else $if T.unaliased_typ is f64 { + return T(strconv.atof_quick(str)) + } $else { + return error('`decode_number_from_string` cannot decode ${T.name} type') + } +} diff --git a/vlib/x/json2/tests/decode_budget_number_test.v b/vlib/x/json2/tests/decode_budget_number_test.v index 123156113..101dd1814 100644 --- a/vlib/x/json2/tests/decode_budget_number_test.v +++ b/vlib/x/json2/tests/decode_budget_number_test.v @@ -1,23 +1,97 @@ import x.json2 as json -fn test_budget_number() { +// Tests for strict mode: quoted strings are NOT decoded as numbers (JSON spec compliance). +// Tests for default mode: quoted strings ARE accepted as numbers for compatibility. + +// ===== STRICT MODE TESTS ===== + +fn test_strict_string_not_decoded_as_int() { + json.decode[int]('"0"', strict: true) or { + assert err is json.JsonDecodeError + if err is json.JsonDecodeError { + assert err.message == 'Data: Expected number, but got string' + } + return + } + assert false, 'Expected JsonDecodeError for quoted number string in strict mode' +} + +fn test_strict_string_not_decoded_as_int_positive() { + json.decode[int]('"100"', strict: true) or { + assert err is json.JsonDecodeError + if err is json.JsonDecodeError { + assert err.message == 'Data: Expected number, but got string' + } + return + } + assert false, 'Expected JsonDecodeError for quoted number string in strict mode' +} + +fn test_strict_string_not_decoded_as_float() { + json.decode[f64]('"-23.6e1"', strict: true) or { + assert err is json.JsonDecodeError + if err is json.JsonDecodeError { + assert err.message == 'Data: Expected number, but got string' + } + return + } + assert false, 'Expected JsonDecodeError for quoted number string in strict mode' +} + +fn test_strict_string_in_array_not_decoded_as_int() { + json.decode[[]int]('["100", 99, "98", 97]', strict: true) or { + assert err is json.JsonDecodeError + if err is json.JsonDecodeError { + assert err.message == 'Data: Expected number, but got string' + } + return + } + assert false, 'Expected JsonDecodeError for quoted number string in array in strict mode' +} + +// ===== DEFAULT MODE TESTS ===== + +fn test_default_string_decoded_as_int() { assert json.decode[int]('"0"')! == 0 assert json.decode[int]('"100"')! == 100 + assert json.decode[int]('"-50"')! == -50 +} + +fn test_default_string_decoded_as_float() { assert json.decode[f64]('"-23.6e1"')! == -236.0 + assert json.decode[f64]('"3.14"')! == 3.14 +} +fn test_default_string_in_array_decoded_as_int() { + assert json.decode[[]int]('["100", "99", "98", "97"]')! == [100, 99, 98, 97] assert json.decode[[]int]('["100", 99, "98", 97]')! == [100, 99, 98, 97] } -fn test_budget_number_malformed() { - json.decode[int]('"+100"') or { - if err is json.JsonDecodeError { - assert err.line == 1 - assert err.character == 2 - assert err.message == 'Syntax: expected digit got +' - } +// ===== COMMON TESTS (both modes) ===== + +fn test_valid_unquoted_numbers() { + assert json.decode[int]('0')! == 0 + assert json.decode[int]('100')! == 100 + assert json.decode[int]('-50')! == -50 + assert json.decode[f64]('-23.6e1')! == -236.0 + assert json.decode[[]int]('[100, 99, 98, 97]')! == [100, 99, 98, 97] + + // Strict mode also works for unquoted numbers + assert json.decode[int]('0', strict: true)! == 0 + assert json.decode[int]('100', strict: true)! == 100 +} +fn test_invalid_number_string_fails() { + json.decode[int]('"not_a_number"') or { return } + assert false, 'Expected error for invalid number string' +} + +fn test_leading_plus_in_string() { + assert json.decode[int]('"+100"')! == 100 + + json.decode[int]('"+100"', strict: true) or { + assert err is json.JsonDecodeError return } - - assert false + assert false, 'Expected error in strict mode' } diff --git a/vlib/x/json2/tests/decode_struct_test.v b/vlib/x/json2/tests/decode_struct_test.v index 66c3e7f1a..1ede86d05 100644 --- a/vlib/x/json2/tests/decode_struct_test.v +++ b/vlib/x/json2/tests/decode_struct_test.v @@ -166,22 +166,30 @@ fn test_number_boundaries() { println('✓ Boundary values test passed') } -fn test_fake_numbers() { - // Test quoted numbers (fake numbers) - fake_number_cases := [ - '{"val1": "123", "val2": "45"}', // Fully quoted - '{"val1": 123, "val2": "45"}', // Mixed format - '{"val1": "255", "val2": 0}', // Boundary value as string +fn test_quoted_numbers_in_strict_mode() { + quoted_number_cases := [ + '{"val1": "123", "val2": "45"}', + '{"val1": 123, "val2": "45"}', + '{"val1": "255", "val2": 0}', ]! - for case in fake_number_cases { - result := json.decode[JsonU8](case) or { - panic('Fake number decoding failed: ${err}, input: ${case}') - } - assert result.val1 >= 0 && result.val1 <= 255 - assert result.val2 >= 0 && result.val2 <= 255 + for case in quoted_number_cases { + json.decode[JsonU8](case, strict: true) or { continue } + panic('Expected decoding to fail for quoted number in strict mode but succeeded: ${case}') + } + println('✓ Quoted numbers correctly rejected in strict mode test passed') +} + +fn test_quoted_numbers_in_default_mode() { + assert json.decode[JsonU8]('{"val1": "123", "val2": "45"}')! == JsonU8{ + val1: 123 + val2: 45 + } + assert json.decode[JsonU8]('{"val1": 123, "val2": "45"}')! == JsonU8{ + val1: 123 + val2: 45 } - println('✓ Fake numbers (quoted numbers) test passed') + println('✓ Quoted numbers accepted in default mode test passed') } fn test_error_conditions() { -- 2.39.5