diff --git a/crates/core/src/transcript/commit.rs b/crates/core/src/transcript/commit.rs index b62da80c3f..ffbc6c7df3 100644 --- a/crates/core/src/transcript/commit.rs +++ b/crates/core/src/transcript/commit.rs @@ -66,6 +66,46 @@ impl TranscriptCommitConfig { .any(|(_, kind)| matches!(kind, TranscriptCommitmentKind::Encoding)) } + /// Returns whether the builder has a commitment for the given direction and + /// range. + /// + /// # Arguments + /// + /// * `direction` - The direction of the transcript. + /// * `range` - The range of the commitment. + pub fn contains( + &self, + ranges: &dyn ToRangeSet, + direction: Direction, + ) -> bool { + let idx = Idx::new(ranges.to_range_set()); + + self.commits + .iter() + .any(|((d, i), _)| *d == direction && *i == idx) + } + + /// Returns whether the builder has a commitment for the given direction and + /// range, with the given kind. + /// + /// # Arguments + /// + /// * `direction` - The direction of the transcript. + /// * `ranges` - The range of the commitment. + /// * `kind` - The kind of commitment. + pub fn contains_with_kind( + &self, + ranges: &dyn ToRangeSet, + direction: Direction, + kind: TranscriptCommitmentKind, + ) -> bool { + let idx = Idx::new(ranges.to_range_set()); + + self.commits + .iter() + .any(|((d, i), k)| *d == direction && *i == idx && *k == kind) + } + /// Returns an iterator over the encoding commitment indices. pub fn iter_encoding(&self) -> impl Iterator { self.commits.iter().filter_map(|(idx, kind)| match kind { diff --git a/crates/data-fixtures/data/json/array b/crates/data-fixtures/data/json/array new file mode 100644 index 0000000000..a95ff12c81 --- /dev/null +++ b/crates/data-fixtures/data/json/array @@ -0,0 +1 @@ +[1,2,3,4,5,6,"7",false,3.14] \ No newline at end of file diff --git a/crates/data-fixtures/data/json/integer b/crates/data-fixtures/data/json/integer new file mode 100644 index 0000000000..b3d3e630fc --- /dev/null +++ b/crates/data-fixtures/data/json/integer @@ -0,0 +1 @@ +-9007199254740991 \ No newline at end of file diff --git a/crates/data-fixtures/data/json/nested_object b/crates/data-fixtures/data/json/nested_object new file mode 100644 index 0000000000..fce66b3dd8 --- /dev/null +++ b/crates/data-fixtures/data/json/nested_object @@ -0,0 +1 @@ +{"foo": "bar", "bazz": 123, "buzz": [1,"5", {"buzz_foo": "bing", "buzz_bazz": 123}]} \ No newline at end of file diff --git a/crates/data-fixtures/data/json/values b/crates/data-fixtures/data/json/values new file mode 100644 index 0000000000..8eac846c67 --- /dev/null +++ b/crates/data-fixtures/data/json/values @@ -0,0 +1,51 @@ +{ + "string_tests": { + "empty": "", + "basic": "Hello, World!", + "escaped": "Quote(\") Tab(\t) Newline(\n) Backslash(\\)", + "unicode": "こんにちは 你好 안녕하세요 مرحبا Привет", + "emojis": "🌟 🌍 🎉 🚀 💻", + "mixed_unicode": "Hello 世界 with emojis 🎮 and symbols ™ ©️ ®️", + "control_chars": "\u0001\u0002\u0003", + "surrogate_pairs": "\uD834\uDD1E", + "special_whitespace": "Space\u2003Em\u2002En\u2000Quarter" + }, + "number_tests": { + "integer": 42, + "negative": -17, + "zero": 0, + "large": 9007199254740991, + "small": -9007199254740991, + "float": 3.14159, + "exponential": 1.23e-4, + "negative_exponential": -4.56E+3 + }, + "boolean_tests": { + "true_value": true, + "false_value": false + }, + "null_test": null, + "array_tests": { + "empty": [], + "numbers": [1, 2, 3, 4, 5], + "mixed": [1, "two", true, null, {"key": "value"}, [1, 2]], + "nested": [[1, 2], [3, 4], [["deep", "array"]]] + }, + "object_tests": { + "empty": {}, + "nested": { + "level1": { + "level2": { + "level3": "deep nesting" + } + } + } + }, + "special_cases": { + "zero_fraction": 0.0, + "one_fraction": 1.0, + "long_string": "This is a somewhat longer string that extends beyond typical lengths to test buffer handling.................................", + "url_safe": "https://example.com/path?param=value&other=123#fragment", + "all_escaped": "\u0022\u005C\u002F\u0008\u000C\u000A\u000D\u0009" + } +} \ No newline at end of file diff --git a/crates/data-fixtures/src/json.rs b/crates/data-fixtures/src/json.rs new file mode 100644 index 0000000000..ba9632369d --- /dev/null +++ b/crates/data-fixtures/src/json.rs @@ -0,0 +1,27 @@ +//! JSON data fixtures + +use crate::define_fixture; + +define_fixture!( + ARRAY, + "A JSON array.", + "../data/json/array" +); + +define_fixture!( + INTEGER, + "A JSON integer.", + "../data/json/integer" +); + +define_fixture!( + NESTED_OBJECT, + "A nested JSON object.", + "../data/json/nested_object" +); + +define_fixture!( + VALUES, + "A JSON object with various values.", + "../data/json/values" +); diff --git a/crates/data-fixtures/src/lib.rs b/crates/data-fixtures/src/lib.rs index 658c7f70d1..ec059d795f 100644 --- a/crates/data-fixtures/src/lib.rs +++ b/crates/data-fixtures/src/lib.rs @@ -1,4 +1,5 @@ pub mod http; +pub mod json; macro_rules! define_fixture { ($name:ident, $doc:tt, $path:tt) => { diff --git a/crates/formats/Cargo.toml b/crates/formats/Cargo.toml index 53ffcfd613..0bd5e8540c 100644 --- a/crates/formats/Cargo.toml +++ b/crates/formats/Cargo.toml @@ -8,6 +8,7 @@ tlsn-core = { workspace = true } bytes = { workspace = true } spansy = { workspace = true, features = ["serde"] } +rangeset = { workspace = true } thiserror = { workspace = true } [dev-dependencies] diff --git a/crates/formats/src/json/commit.rs b/crates/formats/src/json/commit.rs index 725770f5ed..776506018d 100644 --- a/crates/formats/src/json/commit.rs +++ b/crates/formats/src/json/commit.rs @@ -1,5 +1,6 @@ use std::error::Error; +use rangeset::{Difference, RangeSet, ToRangeSet}; use spansy::{json::KeyValue, Spanned}; use tlsn_core::transcript::{Direction, TranscriptCommitConfigBuilder}; @@ -150,15 +151,32 @@ pub trait JsonCommit { .map_err(|e| JsonCommitError::new_with_source("failed to commit array", e))?; if !array.elems.is_empty() { - builder - .commit(&array.without_values(), direction) - .map_err(|e| { - JsonCommitError::new_with_source("failed to commit array excluding values", e) + let without_values = array.without_values(); + + // Commit to the array excluding all values and separators. + builder.commit(&without_values, direction).map_err(|e| { + JsonCommitError::new_with_source("failed to commit array excluding values", e) + })?; + + // Commit to the separators and whitespace of the array + let array_range: RangeSet = array.to_range_set().difference(&without_values); + let difference = array + .elems + .iter() + .map(|e| e.to_range_set()) + .fold(array_range.clone(), |acc, range| acc.difference(&range)); + + for range in difference.iter_ranges() { + builder.commit(&range, direction).map_err(|e| { + JsonCommitError::new_with_source("failed to commit array element", e) })?; - } + } - // TODO: Commit each value separately, but we need a strategy for handling - // separators. + // Commit to the values of the array + for elem in &array.elems { + self.commit_value(builder, elem, direction)?; + } + } Ok(()) } @@ -250,3 +268,76 @@ pub trait JsonCommit { pub struct DefaultJsonCommitter {} impl JsonCommit for DefaultJsonCommitter {} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::*; + use spansy::json::{parse_slice, JsonValue, JsonVisit}; + use tlsn_core::transcript::{ + Transcript, TranscriptCommitConfig, TranscriptCommitConfigBuilder, + }; + use tlsn_data_fixtures::json as fixtures; + + #[rstest] + #[case::array(fixtures::ARRAY)] + #[case::integer(fixtures::INTEGER)] + #[case::json_object(fixtures::NESTED_OBJECT)] + #[case::values(fixtures::VALUES)] + fn test_json_commit(#[case] src: &'static [u8]) { + let transcript = Transcript::new([], src); + let json_data = parse_slice(src).unwrap(); + let mut committer = DefaultJsonCommitter::default(); + let mut builder = TranscriptCommitConfigBuilder::new(&transcript); + + committer + .commit_value(&mut builder, &json_data, Direction::Received) + .unwrap(); + + let config = builder.build().unwrap(); + + struct CommitChecker<'a> { + config: &'a TranscriptCommitConfig, + } + impl<'a> JsonVisit for CommitChecker<'a> { + fn visit_value(&mut self, node: &JsonValue) { + match node { + JsonValue::Object(obj) => { + assert!(self + .config + .contains(&obj.without_pairs(), Direction::Received)); + + for kv in &obj.elems { + assert!(self + .config + .contains(&kv.without_value(), Direction::Received)); + } + + JsonVisit::visit_object(self, obj); + } + + JsonValue::Array(arr) => { + assert!(self + .config + .contains(&arr.without_values(), Direction::Received)); + + JsonVisit::visit_array(self, arr); + } + + _ => { + if !node.span().is_empty() { + assert!( + self.config.contains(node, Direction::Received), + "failed to commit to value ({}), at {:?}", + node.span().as_str(), + node.span() + ); + } + } + } + } + } + + CommitChecker { config: &config }.visit_value(&json_data); + } +}