Skip to content

Commit 548cd67

Browse files
authored
Merge pull request #328 from Concordium/feature/COR-2315-precision-number-handling-verifier
fix: bug fix for precision handling
2 parents 844d672 + e74666f commit 548cd67

6 files changed

Lines changed: 239 additions & 72 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

credential-verification-service/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
## Unreleased
22

3+
## 0.3.1
4+
5+
- fix for precision integer handling in the correct range for public info. Range supported for: `-9223372036854775808` -> `18446744073709551615`
6+
37
## 0.3.0
48

59
- Create verification API and verify presentation API are now changed to accept aribitrary json that is then encoded to CBOR for

credential-verification-service/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "credential-verification-service"
3-
version = "0.3.0"
3+
version = "0.3.1"
44
description = "Credential verification service"
55
edition = "2024"
66

credential-verification-service/docs/api/create_verification_request/examples.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,18 @@ See data model here for request payload strucuture: [Link text](./data_model.md)
44

55
Notes:
66
- `connectionId`, `resourceId`, and `requestedClaims` are required for creating a verification request
7+
78
- `publicInfo` is optional and may be omitted. If provided, it must be a JSON object. The value is treated
89
as arbitrary public metadata and will be re-encoded as CBOR for submission on-chain as part of a registered data transaction.
910
Supported JSON types inside `publicInfo` are: null, boolean, string, number, array, and object. JSON objects become CBOR maps,
1011
arrays become CBOR arrays, strings become CBOR text, booleans/null become CBOR simple values.
1112

12-
Number handling: integer JSON tokens are encoded as CBOR integers (Positive/Negative), and non-integer JSON tokens (fractional/exponent)
13-
are encoded as CBOR float64.
14-
15-
Precision note: to preserve exact integer values across common clients (especially JavaScript), send integers outside the
16-
safe range `-9007199254740991` to `9007199254740991` as JSON strings.
17-
13+
- Json Number handling:
14+
For Integer literals the range accepted is between Negative(i64 MIN) and Positve(u64 Max): `-9223372036854775808` -> `18446744073709551615`.
15+
Note that CBOR for negative is: -(`negative` + 1). Numbers outside this range will be treated as an f64 and may result in precision loss
16+
if the provided number contains 15-16 significant digits. It is advisable to submit the number as a JSON string if using very large numbers
17+
outside the number literal range above or float containing too many significant digits.
18+
1819

1920
a publicInfo sample may be as simple as:
2021

credential-verification-service/docs/api/verify_presentation/examples.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@ as arbitrary public metadata and will be re-encoded as CBOR for submission on-ch
99
Supported JSON types inside `publicInfo` are: null, boolean, string, number, array, and object. JSON objects become CBOR maps,
1010
arrays become CBOR arrays, strings become CBOR text, booleans/null become CBOR simple values.
1111

12-
Number handling: integer JSON tokens are encoded as CBOR integers (Positive/Negative), and non-integer JSON tokens (fractional/exponent)
13-
are encoded as CBOR float64.
14-
15-
Precision note: to preserve exact integer values across common clients (especially JavaScript), send integers outside the
16-
safe range `-9007199254740991` to `9007199254740991` as JSON strings.
12+
- Json Number handling:
13+
For Integer literals the range accepted is between Negative(i64 MIN) and Positve(u64 Max): `-9223372036854775808` -> `18446744073709551615`.
14+
Note that CBOR for negative is: -(`negative` + 1). Numbers outside this range will be treated as an f64 and may result in precision loss
15+
if the provided number contains 15-16 significant digits. It is advisable to submit the number as a JSON string if using very large numbers
16+
outside the number literal range above or float containing too many significant digits.
1717

1818
a publicInfo sample may be as simple as:
1919

credential-verification-service/src/api/util.rs

Lines changed: 221 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -58,35 +58,26 @@ fn json_to_cbor_value(value: &serde_json::Value) -> Result<cbor::value::Value, V
5858
serde_json::Value::String(s) => cbor::value::Value::Text(s.clone()),
5959

6060
serde_json::Value::Number(n) => {
61-
if let Some(i) = n.as_i64() {
62-
if i >= 0 {
63-
cbor::value::Value::Positive(i as u64)
64-
} else {
65-
let mag: u64 =
66-
(-1i128 - i as i128)
67-
.try_into()
68-
.map_err(|_| ValidationError {
69-
details: vec![ErrorDetail {
70-
code: "PUBLIC_INFO_NEGATIVE_INTEGER_OUT_OF_RANGE".to_string(),
71-
path: "publicInfo".to_string(),
72-
message: format!("negative integer out of range: {:?}", i),
73-
}],
74-
})?;
75-
cbor::value::Value::Negative(mag)
76-
}
77-
} else if let Some(u) = n.as_u64() {
78-
cbor::value::Value::Positive(u)
79-
} else if let Some(f) = n.as_f64() {
80-
cbor::value::Value::Float(f)
61+
if let Some(posint) = n.as_u64() {
62+
// postive number that fits in u64
63+
cbor::value::Value::Positive(posint)
64+
} else if let Some(negint) = n.as_i64() {
65+
// this number is definitely negative
66+
let negintmag: u64 = (-1i64 - negint) as u64;
67+
cbor::value::Value::Negative(negintmag)
68+
} else if let Some(float) = n.as_f64() {
69+
// either a float, or an integer that is too big
70+
// for serialization without precision loss
71+
cbor::value::Value::Float(float)
8172
} else {
73+
// this should never happen, as serde_json::Number should always be one of the above 3 cases
74+
// it does happen with way too big Float numbers such as `1e400`. In this case, we produce a
75+
// validation error as it did not fit in u64, i64 or f64.
8276
return Err(ValidationError {
8377
details: vec![ErrorDetail {
8478
code: "PUBLIC_INFO_NUMBER_NOT_REPRESENTABLE".to_string(),
79+
message: format!("Unable to parse this json number. {:?}", n),
8580
path: "publicInfo".to_string(),
86-
message: format!(
87-
"serde_json::Number not representable as i64/u64/f64: {:?}",
88-
n
89-
),
9081
}],
9182
});
9283
}
@@ -114,6 +105,7 @@ fn json_to_cbor_value(value: &serde_json::Value) -> Result<cbor::value::Value, V
114105
mod tests {
115106
use super::*;
116107

108+
/// Positive Test Scenarios
117109
#[test]
118110
fn test_public_info_none_returns_ok_none() {
119111
let public_info: Option<serde_json::Value> = None;
@@ -124,32 +116,6 @@ mod tests {
124116
assert!(result.unwrap().is_none());
125117
}
126118

127-
#[test]
128-
fn test_public_info_not_json_object_at_root_validation_error() {
129-
// The expected validation error
130-
let expected_error = ValidationError {
131-
details: vec![ErrorDetail {
132-
code: "PUBLIC_INFO_EXPECTED_JSON".to_string(),
133-
path: "publicInfo".to_string(),
134-
message: "expected json at top level, got: Number(1)".to_string(),
135-
}],
136-
};
137-
138-
// creating a public info that produces the validation error (just a json number rather than an object at root)
139-
let public_info: Option<serde_json::Value> =
140-
Some(serde_json::Value::Number(serde_json::Number::from(1u64)));
141-
142-
// call our convert function
143-
let result = convert_public_info_to_hashmap_of_string_to_cbor(&public_info);
144-
145-
// ensure we have a result in error
146-
assert!(result.is_err());
147-
148-
let result_error = result.unwrap_err();
149-
150-
assert_eq!(result_error, expected_error);
151-
}
152-
153119
#[test]
154120
fn test_parse_public_info_success_object() {
155121
let session_id_val = "alpha-numeric-1234-5678-sessionid-example";
@@ -179,15 +145,6 @@ mod tests {
179145
);
180146
}
181147

182-
#[test]
183-
fn test_parse_public_info_fails_when_not_object() {
184-
let public_info = Some(serde_json::json!("hello"));
185-
186-
let result = convert_public_info_to_hashmap_of_string_to_cbor(&public_info);
187-
188-
assert!(result.is_err(), "top-level non-object should error");
189-
}
190-
191148
#[test]
192149
fn test_nested_conversion() {
193150
let public_info = Some(serde_json::json!({
@@ -236,4 +193,209 @@ mod tests {
236193
Some(&cbor::value::Value::Negative(211))
237194
);
238195
}
196+
197+
/// Error scenarios and validations.
198+
#[test]
199+
fn test_parse_public_info_fails_when_not_object() {
200+
let public_info = Some(serde_json::json!("hello"));
201+
202+
let result = convert_public_info_to_hashmap_of_string_to_cbor(&public_info);
203+
204+
assert!(result.is_err(), "top-level non-object should error");
205+
}
206+
207+
/// Public Info number parsing tests and constraints
208+
#[test]
209+
fn test_public_info_positive_integer_parsed_to_cbor_positive() {
210+
let public_info: Option<serde_json::Value> =
211+
Some(serde_json::from_str(r#"{"simplePositiveInt": 42}"#).unwrap());
212+
213+
let result = convert_public_info_to_hashmap_of_string_to_cbor(&public_info);
214+
assert!(result.is_ok());
215+
216+
let map = result.unwrap().expect("should have public info");
217+
assert_eq!(
218+
map.get("simplePositiveInt"),
219+
Some(&cbor::value::Value::Positive(42))
220+
);
221+
}
222+
223+
#[test]
224+
fn test_public_info_negative_one_parsed_to_cbor_negative_zero() {
225+
let public_info: Option<serde_json::Value> =
226+
Some(serde_json::from_str(r#"{"simpleNegative": -1}"#).unwrap());
227+
228+
let result = convert_public_info_to_hashmap_of_string_to_cbor(&public_info);
229+
assert!(result.is_ok());
230+
231+
let map = result.unwrap().expect("should have public info");
232+
assert_eq!(
233+
map.get("simpleNegative"),
234+
Some(&cbor::value::Value::Negative(0))
235+
);
236+
}
237+
238+
/// Minimum negative that can be stored in CBOR
239+
#[test]
240+
fn test_public_info_cbor_i64_min_negative() {
241+
let public_info: Option<serde_json::Value> =
242+
Some(serde_json::from_str(r#"{"i64min": -9223372036854775808}"#).unwrap());
243+
244+
let result = convert_public_info_to_hashmap_of_string_to_cbor(&public_info);
245+
assert!(result.is_ok());
246+
247+
let map = result.unwrap().expect("should have public info");
248+
249+
assert_eq!(
250+
map.get("i64min"),
251+
Some(&cbor::value::Value::Negative(i64::MAX as u64))
252+
);
253+
}
254+
255+
/// Max positive that can be stored in CBOR
256+
#[test]
257+
fn test_public_info_cbor_max_u64_positive() {
258+
let public_info: Option<serde_json::Value> =
259+
Some(serde_json::from_str(r#"{"u64max": 18446744073709551615}"#).unwrap());
260+
261+
let result = convert_public_info_to_hashmap_of_string_to_cbor(&public_info);
262+
assert!(result.is_ok());
263+
264+
let map = result.unwrap().expect("should have public info");
265+
assert_eq!(
266+
map.get("u64max"),
267+
Some(&cbor::value::Value::Positive(u64::MAX))
268+
);
269+
}
270+
271+
/// Test standard float works as expected.
272+
#[test]
273+
fn test_public_info_simple_float_parsed_to_cbor_float() {
274+
let public_info: Option<serde_json::Value> =
275+
Some(serde_json::from_str(r#"{"simpleFloat": 0.1}"#).unwrap());
276+
277+
let result = convert_public_info_to_hashmap_of_string_to_cbor(&public_info);
278+
assert!(result.is_ok());
279+
280+
let map = result.unwrap().expect("should have public info");
281+
assert_eq!(
282+
map.get("simpleFloat"),
283+
Some(&cbor::value::Value::Float(0.1))
284+
);
285+
}
286+
287+
/// Ensure float followed by .0 is stored as a Float.
288+
#[test]
289+
fn test_public_info_float_with_dot_zero_stays_float() {
290+
let public_info: Option<serde_json::Value> =
291+
Some(serde_json::from_str(r#"{"onePointZero": 1.0}"#).unwrap());
292+
293+
let result = convert_public_info_to_hashmap_of_string_to_cbor(&public_info);
294+
assert!(result.is_ok());
295+
296+
let map = result.unwrap().expect("should have public info");
297+
assert_eq!(
298+
map.get("onePointZero"),
299+
Some(&cbor::value::Value::Float(1.0))
300+
);
301+
}
302+
303+
/// Ensure scientific numbers with exponential are stored
304+
/// as their true Float value
305+
#[test]
306+
fn test_public_info_scientific_notation_parsed_to_cbor_float() {
307+
let public_info: Option<serde_json::Value> =
308+
Some(serde_json::from_str(r#"{"sci": 1e3}"#).unwrap());
309+
310+
let result = convert_public_info_to_hashmap_of_string_to_cbor(&public_info);
311+
assert!(result.is_ok());
312+
313+
let map = result.unwrap().expect("should have public info");
314+
assert_eq!(map.get("sci"), Some(&cbor::value::Value::Float(1000.0)));
315+
}
316+
317+
/// Test a super large Float, returns with Validation
318+
/// Error as not representable.
319+
#[test]
320+
fn test_public_info_float_out_of_f64_range_errors() {
321+
let public_info: Option<serde_json::Value> =
322+
Some(serde_json::from_str(r#"{"tooBigFloat": 1e400}"#).unwrap());
323+
324+
let result = convert_public_info_to_hashmap_of_string_to_cbor(&public_info);
325+
assert!(result.is_err());
326+
327+
let result = result.unwrap_err();
328+
329+
assert_eq!(result.details.len(), 1);
330+
assert_eq!(
331+
result.details[0].code,
332+
"PUBLIC_INFO_NUMBER_NOT_REPRESENTABLE"
333+
);
334+
assert_eq!(result.details[0].path, "publicInfo");
335+
assert!(
336+
result.details[0]
337+
.message
338+
.contains("Unable to parse this json number")
339+
);
340+
}
341+
342+
// Float with 14 digits after decimal is precise
343+
#[test]
344+
fn test_public_info_float_14_digits_after_decimal_is_precise() {
345+
let public_info: Option<serde_json::Value> =
346+
Some(serde_json::from_str(r#"{"preciseFloat": 99.99999999999999}"#).unwrap());
347+
348+
let result = convert_public_info_to_hashmap_of_string_to_cbor(&public_info);
349+
assert!(result.is_ok());
350+
351+
let map = result.unwrap().expect("should have public info");
352+
assert_eq!(
353+
map.get("preciseFloat"),
354+
Some(&cbor::value::Value::Float(99.99999999999999))
355+
);
356+
}
357+
358+
// Float with 15 digits after decimal loses precision
359+
#[test]
360+
fn test_public_info_float_15_digits_after_decimal_loses_precision() {
361+
let public_info: Option<serde_json::Value> =
362+
Some(serde_json::from_str(r#"{"preciseFloat": 99.999999999999999}"#).unwrap());
363+
364+
let result = convert_public_info_to_hashmap_of_string_to_cbor(&public_info);
365+
assert!(result.is_ok());
366+
367+
let map = result.unwrap().expect("should have public info");
368+
369+
// proof that precision gets lost for this
370+
assert_eq!(
371+
map.get("preciseFloat"),
372+
Some(&cbor::value::Value::Float(100.0))
373+
);
374+
}
375+
376+
#[test]
377+
fn test_public_info_zero_successfully_recorded_as_cbor_positive() {
378+
let public_info: Option<serde_json::Value> =
379+
Some(serde_json::from_str(r#"{"val": 0}"#).unwrap());
380+
381+
let result = convert_public_info_to_hashmap_of_string_to_cbor(&public_info);
382+
assert!(result.is_ok());
383+
384+
let map = result.unwrap().expect("should have public info");
385+
386+
assert_eq!(map.get("val"), Some(&cbor::value::Value::Positive(0)));
387+
}
388+
389+
#[test]
390+
fn test_public_info_neg_zero_successfully_recorded_as_cbor_positive() {
391+
let public_info: Option<serde_json::Value> =
392+
Some(serde_json::from_str(r#"{"val": -0}"#).unwrap());
393+
394+
let result = convert_public_info_to_hashmap_of_string_to_cbor(&public_info);
395+
assert!(result.is_ok());
396+
397+
let map = result.unwrap().expect("should have public info");
398+
399+
assert_eq!(map.get("val"), Some(&cbor::value::Value::Positive(0)));
400+
}
239401
}

0 commit comments

Comments
 (0)