From ea3756e561fd26f49ca0ba49e55493675decf83a Mon Sep 17 00:00:00 2001 From: Treston Cuzzort Date: Sun, 25 Oct 2026 19:15:43 -0500 Subject: [PATCH] json_patch: compare integer and double test values numerically RFC 6902 section 4.6 compares JSON numbers by value. The test operation used json_object_equal(), which treats json_type_int and json_type_double as different types, so a test of 1.0 against 1 failed. Use a private comparison for JSON Patch, including numbers nested inside objects and arrays. Check floating-point range before converting, and require both integer equality and an exact round trip to reject fractional values and large integers that would round to the same double. Add regression cases for both argument orders, nested values, signed and unsigned limits, fractional values, precision loss and non-number types. https://www.rfc-editor.org/rfc/rfc6902.txt section 4.6 --- json_patch.c | 69 ++++++++++++++++++++++++++++++- tests/json_patch_tests.json | 75 ++++++++++++++++++++++++++++++++++ tests/test_json_patch.expected | 24 +++++++++++ 3 files changed, 167 insertions(+), 1 deletion(-) diff --git a/json_patch.c b/json_patch.c index 8fb7f08..904f609 100644 --- a/json_patch.c +++ b/json_patch.c @@ -15,6 +15,7 @@ #include "json_patch.h" #include "json_object_private.h" #include "json_pointer_private.h" +#include "linkhash.h" #include #ifndef SIZE_T_MAX @@ -48,6 +49,72 @@ * RFC 6902 - https://tools.ietf.org/html/rfc6902 */ +/* RFC 6902 compares numbers by value, including inside arrays and objects. + * json_object_equal() deliberately distinguishes integer and double objects. + */ +static int json_patch_equal(struct json_object *a, struct json_object *b) +{ + enum json_type type_a = json_object_get_type(a); + enum json_type type_b = json_object_get_type(b); + + if (a == b) + return 1; + + if ((type_a == json_type_int && type_b == json_type_double) || + (type_a == json_type_double && type_b == json_type_int)) + { + struct json_object *integer = type_a == json_type_int ? a : b; + struct json_object *floating = type_a == json_type_double ? a : b; + double d = json_object_get_double(floating); + int64_t i = json_object_get_int64(integer); + uint64_t u; + + /* Check the range before casting, then reject fractional values and + * integers that would lose precision when converted to double. + */ + if (i < 0) + return d >= (double)INT64_MIN && d < 0 && (int64_t)d == i && (double)i == d; + + u = json_object_get_uint64(integer); + /* As in json_object_get_uint64(), the rounded upper bound is exclusive. */ + return d >= 0 && d < (double)UINT64_MAX && (uint64_t)d == u && (double)u == d; + } + + if (type_a != type_b) + return 0; + + if (type_a == json_type_array) + { + size_t i, len = json_object_array_length(a); + if (len != json_object_array_length(b)) + return 0; + for (i = 0; i < len; i++) + { + if (!json_patch_equal(json_object_array_get_idx(a, i), + json_object_array_get_idx(b, i))) + return 0; + } + return 1; + } + + if (type_a == json_type_object) + { + struct json_object_iter iter; + struct json_object *value; + if (json_object_object_length(a) != json_object_object_length(b)) + return 0; + json_object_object_foreachC(a, iter) + { + if (!json_object_object_get_ex(b, iter.key, &value) || + !json_patch_equal(iter.val, value)) + return 0; + } + return 1; + } + + return json_object_equal(a, b); +} + static int json_patch_apply_test(struct json_object **res, struct json_object *patch_elem, const char *path, struct json_patch_error *patch_error) @@ -65,7 +132,7 @@ static int json_patch_apply_test(struct json_object **res, return -1; } - if (!json_object_equal(value1, value2)) { + if (!json_patch_equal(value1, value2)) { _set_err(ENOENT, "Value of element referenced by 'path' field did not match 'value' field"); return -1; } diff --git a/tests/json_patch_tests.json b/tests/json_patch_tests.json index eb964a8..e2d62d6 100644 --- a/tests/json_patch_tests.json +++ b/tests/json_patch_tests.json @@ -137,6 +137,81 @@ "patch": [{"op":"add", "path": "/3", "value": "bar"}], "error": "index is greater than number of items in array" }, + { "comment": "test integer against equal double", + "doc": {"n": 1}, + "patch": [{"op": "test", "path": "/n", "value": 1.0}], + "expected": {"n": 1} }, + + { "comment": "test double against equal integer", + "doc": {"n": 1.0}, + "patch": [{"op": "test", "path": "/n", "value": 1}], + "expected": {"n": 1.0} }, + + { "comment": "test numeric equality inside arrays and objects", + "doc": {"a": [1, {"n": -2.0}], "b": null}, + "patch": [{"op": "test", "path": "", "value": {"b": null, "a": [1.0, {"n": -2}]}}], + "expected": {"a": [1, {"n": -2.0}], "b": null} }, + + { "comment": "test integer against negative zero", + "doc": 0, + "patch": [{"op": "test", "path": "", "value": -0.0}], + "expected": 0 }, + + { "comment": "test unsigned integer against equal double", + "doc": 9223372036854775808, + "patch": [{"op": "test", "path": "", "value": 9223372036854775808.0}], + "expected": 9223372036854775808 }, + + { "comment": "test minimum signed integer against equal double", + "doc": -9223372036854775808, + "patch": [{"op": "test", "path": "", "value": -9223372036854775808.0}], + "expected": -9223372036854775808 }, + + { "comment": "test does not round integers to doubles", + "doc": 9007199254740993, + "patch": [{"op": "test", "path": "", "value": 9007199254740992.0}], + "error": "different integers must not compare equal after rounding" }, + + { "comment": "test does not round negative integers to doubles", + "doc": -9007199254740993, + "patch": [{"op": "test", "path": "", "value": -9007199254740992.0}], + "error": "different negative integers must not compare equal after rounding" }, + + { "comment": "test does not truncate fractional numbers", + "doc": 1, + "patch": [{"op": "test", "path": "", "value": 1.5}], + "error": "fractional number must not compare equal to an integer" }, + + { "comment": "test does not truncate negative fractional numbers", + "doc": -1, + "patch": [{"op": "test", "path": "", "value": -1.5}], + "error": "negative fractional number must not compare equal to an integer" }, + + { "comment": "test does not clamp out of range unsigned numbers", + "doc": 18446744073709551615, + "patch": [{"op": "test", "path": "", "value": 18446744073709551616.0}], + "error": "out of range double must not compare equal to UINT64_MAX" }, + + { "comment": "test does not clamp out of range negative numbers", + "doc": -9223372036854775808, + "patch": [{"op": "test", "path": "", "value": -18446744073709551616.0}], + "error": "out of range double must not compare equal to INT64_MIN" }, + + { "comment": "test does not coerce boolean values to numbers", + "doc": true, + "patch": [{"op": "test", "path": "", "value": 1.0}], + "error": "boolean is not a number" }, + + { "comment": "test does not coerce strings to numbers", + "doc": "1", + "patch": [{"op": "test", "path": "", "value": 1.0}], + "error": "string is not a number" }, + + { "comment": "test checks numeric equality recursively", + "doc": {"a": [1, {"n": -2}]}, + "patch": [{"op": "test", "path": "", "value": {"a": [1.0, {"n": -3.0}]}}], + "error": "nested numbers differ" }, + { "comment": "test against implementation-specific numeric parsing", "doc": {"1e0": "foo"}, "patch": [{"op": "test", "path": "/1e0", "value": "foo"}], diff --git a/tests/test_json_patch.expected b/tests/test_json_patch.expected index 041c014..c2b5db7 100644 --- a/tests/test_json_patch.expected +++ b/tests/test_json_patch.expected @@ -54,6 +54,30 @@ Testing '(null)', doc '[ "foo", "sil" ]' patch '[ { "op": "add", "path": "\/0", Testing 'push item to array via last index + 1', doc '[ "foo", "sil" ]' patch '[ { "op": "add", "path": "\/2", "value": "bar" } ]' : OK Testing 'add item to array at index > length should fail', doc '[ "foo", "sil" ]' patch '[ { "op": "add", "path": "\/3", "value": "bar" } ]' : OK => json_patch_apply failed as expected: ERRNO=EINVAL at patch idx 0: Failed to set value at path referenced by 'path' field +Testing 'test integer against equal double', doc '{ "n": 1 }' patch '[ { "op": "test", "path": "\/n", "value": 1.0 } ]' : OK +Testing 'test double against equal integer', doc '{ "n": 1.0 }' patch '[ { "op": "test", "path": "\/n", "value": 1 } ]' : OK +Testing 'test numeric equality inside arrays and objects', doc '{ "a": [ 1, { "n": -2.0 } ], "b": null }' patch '[ { "op": "test", "path": "", "value": { "b": null, "a": [ 1.0, { "n": -2 } ] } } ]' : OK +Testing 'test integer against negative zero', doc '0' patch '[ { "op": "test", "path": "", "value": -0.0 } ]' : OK +Testing 'test unsigned integer against equal double', doc '9223372036854775808' patch '[ { "op": "test", "path": "", "value": 9223372036854775808.0 } ]' : OK +Testing 'test minimum signed integer against equal double', doc '-9223372036854775808' patch '[ { "op": "test", "path": "", "value": -9223372036854775808.0 } ]' : OK +Testing 'test does not round integers to doubles', doc '9007199254740993' patch '[ { "op": "test", "path": "", "value": 9007199254740992.0 } ]' : OK + => json_patch_apply failed as expected: ERRNO=ENOENT at patch idx 0: Value of element referenced by 'path' field did not match 'value' field +Testing 'test does not round negative integers to doubles', doc '-9007199254740993' patch '[ { "op": "test", "path": "", "value": -9007199254740992.0 } ]' : OK + => json_patch_apply failed as expected: ERRNO=ENOENT at patch idx 0: Value of element referenced by 'path' field did not match 'value' field +Testing 'test does not truncate fractional numbers', doc '1' patch '[ { "op": "test", "path": "", "value": 1.5 } ]' : OK + => json_patch_apply failed as expected: ERRNO=ENOENT at patch idx 0: Value of element referenced by 'path' field did not match 'value' field +Testing 'test does not truncate negative fractional numbers', doc '-1' patch '[ { "op": "test", "path": "", "value": -1.5 } ]' : OK + => json_patch_apply failed as expected: ERRNO=ENOENT at patch idx 0: Value of element referenced by 'path' field did not match 'value' field +Testing 'test does not clamp out of range unsigned numbers', doc '18446744073709551615' patch '[ { "op": "test", "path": "", "value": 18446744073709551616.0 } ]' : OK + => json_patch_apply failed as expected: ERRNO=ENOENT at patch idx 0: Value of element referenced by 'path' field did not match 'value' field +Testing 'test does not clamp out of range negative numbers', doc '-9223372036854775808' patch '[ { "op": "test", "path": "", "value": -18446744073709551616.0 } ]' : OK + => json_patch_apply failed as expected: ERRNO=ENOENT at patch idx 0: Value of element referenced by 'path' field did not match 'value' field +Testing 'test does not coerce boolean values to numbers', doc 'true' patch '[ { "op": "test", "path": "", "value": 1.0 } ]' : OK + => json_patch_apply failed as expected: ERRNO=ENOENT at patch idx 0: Value of element referenced by 'path' field did not match 'value' field +Testing 'test does not coerce strings to numbers', doc '1' patch '[ { "op": "test", "path": "", "value": 1.0 } ]' : OK + => json_patch_apply failed as expected: ERRNO=ENOENT at patch idx 0: Value of element referenced by 'path' field did not match 'value' field +Testing 'test checks numeric equality recursively', doc '{ "a": [ 1, { "n": -2 } ] }' patch '[ { "op": "test", "path": "", "value": { "a": [ 1.0, { "n": -3.0 } ] } } ]' : OK + => json_patch_apply failed as expected: ERRNO=ENOENT at patch idx 0: Value of element referenced by 'path' field did not match 'value' field Testing 'test against implementation-specific numeric parsing', doc '{ "1e0": "foo" }' patch '[ { "op": "test", "path": "\/1e0", "value": "foo" } ]' : OK Testing 'test with bad number should fail', doc '[ "foo", "bar" ]' patch '[ { "op": "test", "path": "\/1e0", "value": "bar" } ]' : OK => json_patch_apply failed as expected: ERRNO=EINVAL at patch idx 0: Invalid path field