diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 06e7cf989d4..78634309252 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -661,6 +661,24 @@ In Rust the declaration is `DocumentProperty::encrypted_for` (`Option { expect(error.keyword).to.equal('maxLength'); }); - it('should count characters, not bytes; consensus caps the bytes at 4096', async () => { - // 2049 two-byte characters: within the schema's 4096 characters, over the - // 4096 bytes `SystemLimits::max_moderation_charter_description_length` - // enforces in rs-dpp (ModerationCharterDescriptionTooLongError, 11002). + it('should refuse more than 4096 bytes within 4096 characters', async () => { + // 2049 two-byte characters: within `maxLength`, which counts characters, but + // 4098 bytes, over `maxBytes` (DocumentPropertyMaxBytesExceededError, 10421) const raw = await rawProposal(); raw.description = 'é'.repeat(2049); + const errors = validate('submittedCharter', raw).getErrors(); + + expect(errors).to.have.length(1); + expect(errors[0].getCode()).to.equal(10421); + }); + + it('should accept 4096 bytes in two-byte characters', async () => { + const raw = await rawProposal(); + raw.description = 'é'.repeat(2048); + expect(validate('submittedCharter', raw).isValid()).to.be.true(); }); }); diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 32268a6b6d3..d8aa527cfb9 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/dashpay/platform/blob/master/packages/rs-dpp/schema/meta_schemas/document/v1/document-meta.json", - "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", + "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", "type": "object", "$defs": { "referenceOperands": { @@ -545,6 +545,12 @@ "minLength": 1, "maxLength": 256 }, + "maxBytes": { + "description": "Only on string properties, and on the items of a typed array of strings, where it bounds every element: the most bytes the value may take in UTF-8. maxLength counts characters, which are up to four bytes each, so it cannot bound the stored size on its own. An integer from 1 to 65535, no lower than minLength (checked at contract registration). Checked wherever a document's properties are validated, every create and replace included, after the JSON schema; a longer value is refused (DocumentPropertyMaxBytesExceededError, 10421), naming the element (tags[2]) for an item. Available from protocol version 14.", + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, "requiredSince": { "type": "integer", "minimum": 1, @@ -642,6 +648,17 @@ "maxItems" ] }, + "maxBytes": { + "description": "maxBytes is only allowed on string properties; on a typed array it goes on the items", + "properties": { + "type": { + "const": "string" + } + }, + "required": [ + "type" + ] + }, "refersTo": { "description": "refersTo is only allowed on identifier properties, except an identityPublicKey reference with identityProperty, which sits on the key id property: an integer with minimum 0 and maximum 4294967295, the range of a key id", "if": { @@ -895,6 +912,12 @@ "minLength": 1, "maxLength": 256 }, + "maxBytes": { + "description": "Only on string elements: the most bytes every element may take in UTF-8, exactly as maxBytes on a string property. The declaration belongs on the items, not on the array. Available from protocol version 14.", + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, "refersTo": { "description": "Only on identifier elements: what every element refers to, the refersTo declaration of an identifier property with the same keys and the same checks (a reference expression included, which each element must meet on its own), except that identityPublicKey is refused: its keyIdProperty names one sibling key id, which cannot pair with many elements. When a document is created or replaced each element is checked as a single reference is, and the first one that fails refuses the write, its error naming the element by its list path (reasons[2] for the third). A propertyAgreement's referring side is still a property of the referring document or its $ownerId, the same for every element, and its referenced side a property of that element's referenced document. The declaration belongs on the items, not on the array. Available from protocol version 14.", "$ref": "#/$defs/documentSchema/properties/refersTo", @@ -966,6 +989,17 @@ "maxItems" ] }, + "maxBytes": { + "description": "maxBytes is only allowed on string elements", + "properties": { + "type": { + "const": "string" + } + }, + "required": [ + "type" + ] + }, "byteArray": { "description": "should be used only with array type", "properties": { diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/mod.rs index 89eac48eb62..6f33c592f46 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/mod.rs @@ -71,6 +71,7 @@ mod tests { item_type: Box::new(DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: Some(16), + max_bytes: None, })), item_constraints: Default::default(), min_items: Some(1), diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index f73d7da4be3..85ea45c9341 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -206,6 +206,8 @@ fn insert_values( property_type => { let property_type = apply_property_reference(&inner_properties, property_type, platform_version)?; + let property_type = + apply_max_bytes(&inner_properties, property_type, platform_version)?; let distinct_from = apply_distinct_from(&inner_properties, &property_type, platform_version)?; let encrypted_for = @@ -340,6 +342,7 @@ fn insert_values_nested( let property_type = apply_property_reference(&inner_properties, property_type, platform_version)?; + let property_type = apply_max_bytes(&inner_properties, property_type, platform_version)?; let distinct_from = apply_distinct_from(&inner_properties, &property_type, platform_version)?; let encrypted_for = apply_encrypted_for(&inner_properties, &property_type, platform_version)?; @@ -397,18 +400,12 @@ fn apply_distinct_from_v0( // A typed array carries the declaration on its `items`: every element must // differ from the named value, so the elements must be identifiers if let DocumentPropertyType::TypedArray(typed_array) = property_type { - if inner_properties.contains_key(property_names::DISTINCT_FROM) { - return Err(DataContractError::InvalidContractStructure( - "distinctFrom on a typed array belongs on its items, where it applies to every \ - element" - .to_string(), - )); - } - let items_map = match inner_properties.get(property_names::ITEMS) { - Some(items) => items.to_btree_ref_string_map()?, - None => return Ok(None), - }; - let Some(distinct_from_value) = items_map.get(property_names::DISTINCT_FROM) else { + let Some(distinct_from_value) = typed_array_items_keyword( + inner_properties, + property_names::DISTINCT_FROM, + "applies to every element", + )? + else { return Ok(None); }; if !matches!( @@ -532,6 +529,116 @@ fn validate_distinct_from_targets_v0( Ok(()) } +/// The value of an element keyword on the `items` of a typed array property, +/// refused on the array itself: the keyword binds every element, so it belongs +/// on the items. `binds` finishes the refusal ("applies to every element"). +fn typed_array_items_keyword<'a>( + inner_properties: &BTreeMap, + keyword: &str, + binds: &str, +) -> Result, DataContractError> { + if inner_properties.contains_key(keyword) { + return Err(DataContractError::InvalidContractStructure(format!( + "{keyword} on a typed array belongs on its items, where it {binds}" + ))); + } + let Some(items) = inner_properties.get(property_names::ITEMS) else { + return Ok(None); + }; + Ok(items.to_btree_ref_string_map()?.get(keyword).copied()) +} + +/// Folds a `maxBytes` declaration into a string property's sizes, or, declared +/// on the `items` of a typed array of strings, into the element type's sizes: +/// the most UTF-8 bytes the value (every element) may take. `maxLength` counts +/// characters, which are up to four bytes each, so it cannot bound the stored +/// size on its own. +/// +/// Versioned on `apply_max_bytes` in the platform version's document type +/// schema versions. `None` selects the behavior of the versions that predate +/// the keyword: it is ignored entirely, so their parses stay byte-for-byte +/// identical to what they always produced. +fn apply_max_bytes( + inner_properties: &BTreeMap, + property_type: DocumentPropertyType, + platform_version: &PlatformVersion, +) -> Result { + match platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .apply_max_bytes + { + None => Ok(property_type), + Some(0) => apply_max_bytes_v0(inner_properties, property_type), + Some(version) => Err(DataContractError::Unsupported(format!( + "apply_max_bytes version {version} is not supported" + ))), + } +} + +fn apply_max_bytes_v0( + inner_properties: &BTreeMap, + mut property_type: DocumentPropertyType, +) -> Result { + let declared = if matches!(property_type, DocumentPropertyType::TypedArray(_)) { + typed_array_items_keyword( + inner_properties, + property_names::MAX_BYTES, + "bounds every element", + )? + } else { + inner_properties.get(property_names::MAX_BYTES).copied() + }; + let Some(max_bytes_value) = declared else { + return Ok(property_type); + }; + let sizes = match &mut property_type { + DocumentPropertyType::String(sizes) => sizes, + DocumentPropertyType::TypedArray(typed_array) => match typed_array.item_type.as_mut() { + DocumentPropertyType::String(sizes) => sizes, + _ => { + return Err(DataContractError::InvalidContractStructure( + "maxBytes is only allowed on string elements of a typed array".to_string(), + )) + } + }, + _ => { + return Err(DataContractError::InvalidContractStructure( + "maxBytes is only allowed on string properties".to_string(), + )) + } + }; + sizes.max_bytes = Some(parse_max_bytes(max_bytes_value, sizes.min_length)?); + Ok(property_type) +} + +/// A `maxBytes` bound: 1 to 65535, and no lower than `minLength`, since a +/// string of `minLength` characters is at least that many bytes and a bound +/// below it would refuse every value. +fn parse_max_bytes(value: &Value, min_length: Option) -> Result { + let max_bytes = value + .to_integer::() + .ok() + .filter(|max_bytes| *max_bytes > 0) + .ok_or_else(|| { + DataContractError::InvalidContractStructure( + "maxBytes must be an integer from 1 to 65535".to_string(), + ) + })?; + if let Some(min_length) = min_length { + if max_bytes < min_length { + return Err(DataContractError::InvalidContractStructure(format!( + "maxBytes {max_bytes} is below minLength {min_length}: a string of \ + {min_length} characters is at least {min_length} bytes, so no value could \ + be valid" + ))); + } + } + Ok(max_bytes) +} + /// Folds a `refersTo` declaration into the property type: an identifier property /// with `refersTo` becomes `IdentifierWithReference(target)`, and a `u32` key id /// property with an `identityPublicKey` declaration naming `identityProperty` diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/max_bytes_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/max_bytes_tests.rs new file mode 100644 index 00000000000..5c6249e4de7 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/max_bytes_tests.rs @@ -0,0 +1,368 @@ +//! `maxBytes`: the property keyword that bounds a string by its UTF-8 length, +//! which plain JSON Schema cannot count (`maxLength` counts characters). +//! +//! The grammar is the v3 document meta-schema's (protocol version 14) and the +//! parse is `apply_max_bytes` 0, which the tables select from protocol version +//! 14 only and which folds the bound into the string's `StringPropertySizes`. +//! The write-time check is `validate_max_bytes_properties`, which +//! `DataContract::validate_document_properties` runs after the JSON schema +//! validation. + +use super::typed_array_test_helpers::{ + expect_json_schema_error, expect_structure_error, parse_dispatched, +}; +use super::*; +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::data_contract::document_type::methods::DocumentTypeBasicMethods; +use crate::data_contract::document_type::StringPropertySizes; +use crate::validation::SimpleConsensusValidationResult; +use platform_value::platform_value; +use rand::rngs::StdRng; +use rand::SeedableRng; + +/// A document type with a string `note` (declaring `note_max_bytes` when +/// given), a typed string array `tags` whose elements take at most 8 bytes, +/// and an object `meta` holding a string `meta.tag` of at most 4 bytes. +fn schema(note_max_bytes: Option) -> Value { + let mut note = platform_value!({ "type": "string", "maxLength": 64, "position": 0 }); + if let Some(max_bytes) = note_max_bytes { + note.insert("maxBytes".to_string(), max_bytes) + .expect("note is a map"); + } + platform_value!({ + "type": "object", + "properties": { + "note": note, + "tags": { + "type": "array", + "maxItems": 4, + "items": { "type": "string", "maxLength": 16, "maxBytes": 8 }, + "position": 1 + }, + "meta": { + "type": "object", + "position": 2, + "properties": { + "tag": { "type": "string", "maxLength": 16, "maxBytes": 4, "position": 0 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }) +} + +/// A document type with the one property `value` declared by `value`. +fn schema_with(value: Value) -> Value { + platform_value!({ + "type": "object", + "properties": { "value": value }, + "additionalProperties": false + }) +} + +fn parse(schema: Value) -> DocumentType { + parse_dispatched(schema, PlatformVersion::latest(), true).expect("the schema parses") +} + +fn property_type(document_type: &DocumentType, path: &str) -> DocumentPropertyType { + document_type + .as_ref() + .flattened_properties() + .get(path) + .unwrap_or_else(|| panic!("{path} is parsed")) + .property_type + .clone() +} + +/// The `maxBytes` a string, or the string elements of a typed array, carry. +fn max_bytes_of(property_type: &DocumentPropertyType) -> Option { + match property_type { + DocumentPropertyType::String(sizes) => sizes.max_bytes, + DocumentPropertyType::TypedArray(typed_array) => match typed_array.item_type.as_ref() { + DocumentPropertyType::String(sizes) => sizes.max_bytes, + other => panic!("expected string elements, got {other:?}"), + }, + other => panic!("expected a string, got {other:?}"), + } +} + +fn first_basic_error(result: SimpleConsensusValidationResult) -> BasicError { + match result.errors.into_iter().next() { + Some(ConsensusError::BasicError(error)) => error, + other => panic!("expected a basic error, got {other:?}"), + } +} + +// ================================================================ +// Parse +// ================================================================ + +#[test] +fn should_fold_max_bytes_into_the_sizes_of_strings_and_of_typed_string_elements() { + let document_type = parse(schema(Some(Value::U64(8)))); + + assert_eq!( + property_type(&document_type, "note"), + DocumentPropertyType::String(StringPropertySizes { + min_length: None, + max_length: Some(64), + max_bytes: Some(8), + }) + ); + assert_eq!( + max_bytes_of(&property_type(&document_type, "meta.tag")), + Some(4) + ); + // Declared on the items, carried by the element type, bounding every element + assert_eq!( + max_bytes_of(&property_type(&document_type, "tags")), + Some(8) + ); + + let without = parse(schema(None)); + assert_eq!(max_bytes_of(&property_type(&without, "note")), None); +} + +#[test] +fn should_refuse_max_bytes_on_a_property_that_is_not_a_string() { + let schema = schema_with(platform_value!({ + "type": "integer", + "minimum": 0, + "maximum": 100, + "maxBytes": 1, + "position": 0 + })); + let error = expect_json_schema_error(parse_dispatched( + schema.clone(), + PlatformVersion::latest(), + true, + )); + assert_eq!(error.keyword(), "const", "{error:?}"); + + // A parse that skips the meta-schema still refuses it + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + "maxBytes is only allowed on string properties", + ); +} + +#[test] +fn should_refuse_max_bytes_on_a_typed_array_itself() { + let schema = schema_with(platform_value!({ + "type": "array", + "maxItems": 4, + "maxBytes": 8, + "items": { "type": "string", "maxLength": 16 }, + "position": 0 + })); + expect_json_schema_error(parse_dispatched( + schema.clone(), + PlatformVersion::latest(), + true, + )); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + "maxBytes on a typed array belongs on its items, where it bounds every element", + ); +} + +#[test] +fn should_refuse_max_bytes_on_elements_that_are_not_strings() { + let schema = schema_with(platform_value!({ + "type": "array", + "maxItems": 4, + "items": { "type": "integer", "minimum": 0, "maximum": 9, "maxBytes": 1 }, + "position": 0 + })); + expect_json_schema_error(parse_dispatched( + schema.clone(), + PlatformVersion::latest(), + true, + )); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + "only allowed on string elements", + ); +} + +#[test] +fn should_refuse_a_max_bytes_of_zero_or_below_min_length() { + let error = expect_json_schema_error(parse_dispatched( + schema(Some(Value::U64(0))), + PlatformVersion::latest(), + true, + )); + assert_eq!(error.keyword(), "minimum", "{error:?}"); + + // Two characters are at least two bytes, so a one-byte bound refuses every value + expect_structure_error( + parse_dispatched( + schema_with(platform_value!({ + "type": "string", + "minLength": 2, + "maxLength": 8, + "maxBytes": 1, + "position": 0 + })), + PlatformVersion::latest(), + true, + ), + "maxBytes 1 is below minLength 2", + ); +} + +#[test] +fn should_ignore_max_bytes_before_protocol_version_14() { + // Protocol version 13's meta-schema refuses the keyword; a parse that skips it + // (a contract read back from state) ignores it, as it always did + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let document_type = parse_dispatched( + schema_with(platform_value!({ + "type": "string", + "maxLength": 8, + "maxBytes": 4, + "position": 0 + })), + platform_version, + false, + ) + .expect("a parse predating maxBytes ignores it"); + assert_eq!(max_bytes_of(&property_type(&document_type, "value")), None); +} + +// ================================================================ +// Sizes +// ================================================================ + +/// `maxLength` alone allows four bytes a character; a declared `maxBytes` is +/// the real bound, for size estimates and for the characters that fit. +#[test] +fn should_bound_the_sizes_of_a_string_by_its_max_bytes() { + let platform_version = PlatformVersion::latest(); + let string = |max_length: Option, max_bytes: Option| { + DocumentPropertyType::String(StringPropertySizes { + min_length: None, + max_length, + max_bytes, + }) + }; + + for (max_length, max_bytes, byte_size, size) in [ + (Some(64), None, 256, 64), + (Some(64), Some(16), 16, 16), + (Some(4), Some(100), 16, 4), + (None, Some(16), 16, 16), + (None, None, u16::MAX, 16383), + ] { + let property_type = string(max_length, max_bytes); + assert_eq!( + property_type + .max_byte_size(platform_version) + .expect("a byte size"), + Some(byte_size), + "{max_length:?} / {max_bytes:?}" + ); + assert_eq!( + property_type.max_size(), + Some(size), + "{max_length:?} / {max_bytes:?}" + ); + } +} + +/// Random documents of a type with a byte cap stay within it, so strategy +/// tests and random fixtures produce documents consensus accepts. +#[test] +fn should_generate_random_strings_within_their_max_bytes() { + let document_type = parse(schema(Some(Value::U64(8)))); + let mut rng = StdRng::seed_from_u64(7); + for path in ["note", "meta.tag", "tags"] { + let property_type = property_type(&document_type, path); + let max_bytes = max_bytes_of(&property_type).expect("a byte cap") as usize; + for _ in 0..50 { + let values = match property_type.random_value(&mut rng) { + Value::Array(elements) => elements, + value => vec![value], + }; + for value in values { + let text = value.as_text().expect("a string"); + assert!(text.len() <= max_bytes, "{path}: {text:?}"); + } + } + } +} + +// ================================================================ +// Write time +// ================================================================ + +#[test] +fn should_refuse_a_string_over_its_max_bytes_by_its_utf8_length() { + let document_type = parse(schema(Some(Value::U64(8)))); + let platform_version = PlatformVersion::latest(); + + // Eight bytes in one-byte or two-byte characters fit; five two-byte characters do + // not, though they are well within maxLength + for note in ["abcdefgh", "éééé"] { + let result = document_type + .validate_max_bytes_properties(&platform_value!({ "note": note }), platform_version) + .expect("validation executes"); + assert!(result.is_valid(), "{note}: {:?}", result.errors); + } + let result = document_type + .validate_max_bytes_properties(&platform_value!({ "note": "ééééé" }), platform_version) + .expect("validation executes"); + assert!(matches!( + first_basic_error(result), + BasicError::DocumentPropertyMaxBytesExceededError(e) + if e.property() == "note" && e.byte_length() == 10 && e.max_bytes() == 8 + )); + + // A nested string is named by its dotted path + let result = document_type + .validate_max_bytes_properties( + &platform_value!({ "meta": { "tag": "ééé" } }), + platform_version, + ) + .expect("validation executes"); + assert!(matches!( + first_basic_error(result), + BasicError::DocumentPropertyMaxBytesExceededError(e) + if e.property() == "meta.tag" && e.byte_length() == 6 && e.max_bytes() == 4 + )); + + // An absent property is not checked + assert!(document_type + .validate_max_bytes_properties(&platform_value!({}), platform_version) + .expect("validation executes") + .is_valid()); +} + +#[test] +fn should_name_the_element_of_a_typed_array_over_its_max_bytes() { + let document_type = parse(schema(None)); + let result = document_type + .validate_max_bytes_properties( + &platform_value!({ "tags": ["short", "ééééé", "ok"] }), + PlatformVersion::latest(), + ) + .expect("validation executes"); + assert!(matches!( + first_basic_error(result), + BasicError::DocumentPropertyMaxBytesExceededError(e) + if e.property() == "tags[1]" && e.byte_length() == 10 && e.max_bytes() == 8 + )); +} + +#[test] +fn should_check_nothing_before_protocol_version_14() { + // A type parsed with the keyword, judged under protocol version 13's method table + let document_type = parse(schema(Some(Value::U64(1)))); + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + assert!(document_type + .validate_max_bytes_properties(&platform_value!({ "note": "too long" }), platform_version) + .expect("validation executes") + .is_valid()); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 877f876e2d6..ff0759b1ad8 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -900,6 +900,8 @@ mod index_only_tests; mod keep_history_tests; #[cfg(all(test, feature = "validation"))] mod list_element_reference_tests; +#[cfg(all(test, feature = "validation"))] +mod max_bytes_tests; #[cfg(test)] mod meta_schema_v0_stray_keyword_tests; #[cfg(test)] diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs index b5ee8a75e41..7aead89918c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs @@ -147,6 +147,7 @@ fn should_parse_every_scalar_element_type() { DocumentPropertyType::String(StringPropertySizes { min_length: Some(1), max_length: Some(20), + max_bytes: None, }), ), ( diff --git a/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs b/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs index f2ce33dbaed..52eda7adf49 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs @@ -208,6 +208,7 @@ mod tests { property_type: DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }), required: true, required_since: None, diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs index 3ceb6d8eace..800bcb1a92e 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs @@ -14,11 +14,15 @@ use crate::version::PlatformVersion; use crate::ProtocolError; #[cfg(feature = "validation")] -use crate::consensus::basic::document::InvalidEncryptedPropertyShapeError; +use crate::consensus::basic::document::{ + DocumentPropertyMaxBytesExceededError, InvalidEncryptedPropertyShapeError, +}; use crate::data_contract::document_type::accessors::{ DocumentTypeV0Getters, DocumentTypeV2Getters, }; use crate::data_contract::document_type::methods::versioned_methods::DocumentTypeV0MethodsVersioned; +#[cfg(feature = "validation")] +use crate::data_contract::document_type::{DocumentPropertyType, StringPropertySizes}; use crate::fee::Credits; use crate::voting::vote_polls::VotePoll; #[cfg(feature = "validation")] @@ -133,6 +137,94 @@ pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { SimpleConsensusValidationResult::new() } + /// Checks every string `properties` (the document's properties map) supplies for a + /// string declaring `maxBytes` against it, counting UTF-8 bytes: the property's value, + /// or every element of a typed array of strings, whose error names the element + /// (`tags[2]`). A declared property the document leaves out is not checked, and a value + /// that is not a string holds no bytes to count: the JSON schema validation that + /// `DataContract::validate_document_properties` runs alongside refuses it. + /// + /// Versioned on `validate_max_bytes` in the document type method versions: `None` + /// before protocol version 14 returns an empty result, which keeps the shipped document + /// validation that calls it inert. + #[cfg(feature = "validation")] + fn validate_max_bytes_properties( + &self, + properties: &Value, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .contract_versions + .document_type_versions + .methods + .validate_max_bytes + { + None => Ok(SimpleConsensusValidationResult::default()), + Some(0) => Ok(self.validate_max_bytes_properties_v0(properties)), + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "validate_max_bytes_properties".to_string(), + known_versions: vec![0], + received: version, + }), + } + } + + #[cfg(feature = "validation")] + fn validate_max_bytes_properties_v0( + &self, + properties: &Value, + ) -> SimpleConsensusValidationResult { + let over = |path: String, value: &Value, max_bytes: u16| { + let length = value.as_text()?.len(); + (length > max_bytes as usize).then(|| { + DocumentPropertyMaxBytesExceededError::new( + path, + u32::try_from(length).unwrap_or(u32::MAX), + max_bytes, + ) + }) + }; + for (path, property) in self.flattened_properties() { + // A lookup error (an intermediate that is not a map) reads as absent: the schema + // validation refuses that shape on its own + let error = match &property.property_type { + DocumentPropertyType::String(StringPropertySizes { + max_bytes: Some(max_bytes), + .. + }) => { + let Ok(Some(value)) = properties.get_optional_value_at_path(path) else { + continue; + }; + over(path.clone(), value, *max_bytes) + } + // A typed array declares on its items: every element is bounded on its own + DocumentPropertyType::TypedArray(typed_array) => { + let DocumentPropertyType::String(StringPropertySizes { + max_bytes: Some(max_bytes), + .. + }) = typed_array.item_type.as_ref() + else { + continue; + }; + let Ok(Some(Value::Array(elements))) = + properties.get_optional_value_at_path(path) + else { + continue; + }; + elements.iter().enumerate().find_map(|(index, element)| { + over(format!("{path}[{index}]"), element, *max_bytes) + }) + } + _ => continue, + }; + if let Some(error) = error { + return SimpleConsensusValidationResult::new_with_error(error.into()); + } + } + SimpleConsensusValidationResult::new() + } + fn top_level_indices(&self) -> Vec<&IndexProperty> { self.indexes() .values() diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs index a9f57d4643a..1cfe620ab34 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs @@ -2909,6 +2909,118 @@ mod tests { } } + mod max_bytes { + use super::*; + use crate::consensus::basic::BasicError; + use crate::data_contract::config::DataContractConfig; + use std::collections::BTreeMap; + + /// A string `note` with `maxBytes` as `note_bound`, and a typed string array + /// `tags` whose `items` carry `maxBytes` as `tags_bound`. + fn document_type( + note_bound: Option, + tags_bound: Option, + platform_version: &PlatformVersion, + ) -> DocumentType { + let mut note = platform_value!({ "type": "string", "maxLength": 64, "position": 0 }); + if let Some(max_bytes) = note_bound { + note.insert("maxBytes".to_string(), max_bytes.into()) + .expect("should insert maxBytes"); + } + let mut items = platform_value!({ "type": "string", "maxLength": 16 }); + if let Some(max_bytes) = tags_bound { + items + .insert("maxBytes".to_string(), max_bytes.into()) + .expect("should insert maxBytes"); + } + + let schema = platform_value!({ + "type": "object", + "properties": { + "note": note, + "tags": { "type": "array", "maxItems": 4, "items": items, "position": 1 } + }, + "signatureSecurityLevelRequirement": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + DocumentType::try_from_schema( + Identifier::random(), + 1, + config.version(), + "test", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") + } + + fn compatibility( + old: (Option, Option), + new: (Option, Option), + ) -> SimpleConsensusValidationResult { + let platform_version = PlatformVersion::latest(); + let old_document_type = document_type(old.0, old.1, platform_version); + let new_document_type = document_type(new.0, new.1, platform_version); + old_document_type + .as_ref() + .validate_schema(new_document_type.as_ref(), platform_version) + .expect("failed to validate schema compatibility") + } + + /// `maxBytes` moves like `maxLength`: every stored string still fits a + /// raised or dropped bound, on a property and on typed array elements. + #[test] + fn should_return_valid_result_when_max_bytes_is_raised_or_removed() { + for (old_bound, new_bound) in [(Some(8), Some(16)), (Some(8), None), (Some(8), Some(8))] + { + for (old, new) in [ + ((old_bound, None), (new_bound, None)), + ((None, old_bound), (None, new_bound)), + ] { + let result = compatibility(old, new); + assert!(result.is_valid(), "{old:?} -> {new:?}: {:?}", result.errors); + } + } + } + + /// A stored string may be longer than a new or lowered bound. + #[test] + fn should_return_invalid_result_when_max_bytes_is_added_or_lowered() { + for (old_bound, new_bound) in [(None, Some(8)), (Some(16), Some(8))] { + for (old, new, changed_path) in [ + ( + (old_bound, None), + (new_bound, None), + "/properties/note/maxBytes", + ), + ( + (None, old_bound), + (None, new_bound), + "/properties/tags/items/maxBytes", + ), + ] { + let result = compatibility(old, new); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + )] if e.property_path() == changed_path, + "{old:?} -> {new:?}" + ); + } + } + } + } + mod validate_byte_array_encoding { use super::*; use std::collections::BTreeMap; diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index 55dd9f21537..016a08d6522 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -158,6 +158,11 @@ pub(crate) mod property_names { pub const SENDER_KEY: &str = "senderKey"; /// `encryptedFor`: the scheme name, one of `EncryptionScheme::ALL`. pub const SCHEME: &str = "scheme"; + /// Property-level integer on a string property, or on the `items` of a + /// typed array of strings: the most UTF-8 bytes a value may hold. + /// Meta-schema v3+ (protocol version 14). See `apply_max_bytes` in + /// `try_from_schema`. + pub const MAX_BYTES: &str = "maxBytes"; pub const KEY_REQUIREMENTS: &str = "keyRequirements"; pub const PURPOSE: &str = "purpose"; pub const BOUND_TO: &str = "boundTo"; diff --git a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs index a5d0f3cf976..cd6509fa52e 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs @@ -211,6 +211,12 @@ impl DocumentProperty { pub struct StringPropertySizes { pub min_length: Option, pub max_length: Option, + /// The most UTF-8 bytes a value may take (`maxBytes`, meta-schema v3, + /// protocol version 14). `max_length` counts characters, which are up to + /// four bytes each. `None` on every string that declares none, which is + /// every string parsed before protocol version 14. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_bytes: Option, } #[derive(Debug, PartialEq, Clone, Serialize)] @@ -1527,6 +1533,7 @@ impl DocumentPropertyType { "string" => Ok(DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, })), "byteArray" => Ok(DocumentPropertyType::ByteArray(ByteArrayPropertySizes { min_size: None, @@ -1735,6 +1742,15 @@ impl DocumentPropertyType { DocumentPropertyType::U8 => Ok(Some(1)), DocumentPropertyType::I8 => Ok(Some(1)), DocumentPropertyType::F64 => Ok(Some(8)), + // A declared `maxBytes` bounds the value directly, below the four bytes a + // character may take; only strings parsed from protocol version 14 carry one + DocumentPropertyType::String(StringPropertySizes { + max_length, + max_bytes: Some(max_bytes), + .. + }) => Ok(Some(max_length.map_or(*max_bytes, |length| { + length.saturating_mul(4).min(*max_bytes) + }))), DocumentPropertyType::String(sizes) => match sizes.max_length { None => Ok(Some(u16::MAX)), Some(size) => { @@ -1782,9 +1798,12 @@ impl DocumentPropertyType { DocumentPropertyType::U8 => Some(1), DocumentPropertyType::I8 => Some(1), DocumentPropertyType::F64 => Some(8), - DocumentPropertyType::String(sizes) => match sizes.max_length { - None => Some(16383), - Some(size) => Some(size), + // No more characters than `maxBytes` fit, since each takes at least a byte + DocumentPropertyType::String(sizes) => match (sizes.max_length, sizes.max_bytes) { + (None, None) => Some(16383), + (Some(size), None) => Some(size), + (None, Some(max_bytes)) => Some(max_bytes.min(16383)), + (Some(size), Some(max_bytes)) => Some(size.min(max_bytes)), }, DocumentPropertyType::ByteArray(sizes) => match sizes.max_size { None => Some(u16::MAX), @@ -4015,6 +4034,7 @@ impl DocumentPropertyType { "string" => DocumentPropertyType::String(StringPropertySizes { min_length: value_map.get_optional_integer(property_names::MIN_LENGTH)?, max_length: value_map.get_optional_integer(property_names::MAX_LENGTH)?, + max_bytes: None, }), "array" => { // Only handling bytearrays for v1 @@ -4190,6 +4210,7 @@ mod tests { DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }), "string", ), @@ -4335,12 +4356,14 @@ mod tests { let no_min = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); assert_eq!(no_min.min_size(), Some(0)); let with_min = DocumentPropertyType::String(StringPropertySizes { min_length: Some(5), max_length: None, + max_bytes: None, }); assert_eq!(with_min.min_size(), Some(5)); } @@ -4425,12 +4448,14 @@ mod tests { let no_max = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); assert_eq!(no_max.max_size(), Some(16383)); let with_max = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: Some(100), + max_bytes: None, }); assert_eq!(with_max.max_size(), Some(100)); } @@ -4523,6 +4548,7 @@ mod tests { let s = DocumentPropertyType::String(StringPropertySizes { min_length: Some(10), max_length: None, + max_bytes: None, }); // protocol version > 8 => checked_mul(4) assert_eq!(s.min_byte_size(pv).unwrap(), Some(40)); @@ -4534,6 +4560,7 @@ mod tests { let s = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); assert_eq!(s.min_byte_size(pv).unwrap(), Some(0)); } @@ -4544,6 +4571,7 @@ mod tests { let s = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: Some(100), + max_bytes: None, }); assert_eq!(s.max_byte_size(pv).unwrap(), Some(400)); } @@ -4554,6 +4582,7 @@ mod tests { let s = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); assert_eq!(s.max_byte_size(pv).unwrap(), Some(u16::MAX)); } @@ -4619,6 +4648,7 @@ mod tests { let s = DocumentPropertyType::String(StringPropertySizes { min_length: Some(0), max_length: Some(100), + max_bytes: None, }); // min_size=0, max_size=100 => (0+100)/2 = 50 assert_eq!(s.middle_size(pv), Some(50)); @@ -4630,6 +4660,7 @@ mod tests { let s = DocumentPropertyType::String(StringPropertySizes { min_length: Some(0), max_length: Some(101), + max_bytes: None, }); // min_size=0, max_size=101 => ceil((0+101)/2) = 51 assert_eq!(s.middle_size_ceil(pv), Some(51)); @@ -4668,6 +4699,7 @@ mod tests { let s = DocumentPropertyType::String(StringPropertySizes { min_length: Some(1), max_length: Some(10), + max_bytes: None, }); // min_byte_size = 1*4 = 4, max_byte_size = 10*4 = 40 // ceil((4+40)/2) = 22 @@ -4701,6 +4733,7 @@ mod tests { assert!(!DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }) .is_integer()); } @@ -4891,6 +4924,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let result = prop .encode_value_for_tree_keys(&Value::Text("".to_string())) @@ -4903,6 +4937,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let result = prop .encode_value_for_tree_keys(&Value::Text("hello".to_string())) @@ -4964,6 +4999,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let result = prop.decode_value_for_tree_keys(&[0]).unwrap(); assert_eq!(result, Value::Text("".to_string())); @@ -4974,6 +5010,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let result = prop.decode_value_for_tree_keys(b"hello").unwrap(); assert_eq!(result, Value::Text("hello".to_string())); @@ -5155,6 +5192,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let result = prop .encode_value_with_size(Value::Text("hi".to_string()), true) @@ -5340,6 +5378,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let result = prop.encode_value_with_size(Value::U64(42), true); assert!(result.is_err()); @@ -5361,6 +5400,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let val = Value::Text("test".to_string()); let result = prop.encode_value_ref_with_size(&val, true).unwrap(); @@ -5535,6 +5575,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let result = prop.value_from_string("hello").unwrap(); assert_eq!(result, Value::Text("hello".to_string())); @@ -5545,6 +5586,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: Some(10), max_length: None, + max_bytes: None, }); let result = prop.value_from_string("hi"); assert!(result.is_err()); @@ -5555,6 +5597,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: Some(3), + max_bytes: None, }); let result = prop.value_from_string("hello"); assert!(result.is_err()); @@ -5767,6 +5810,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); // varint 2^62 followed by two bytes of payload let mut data = vec![0xffu8, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f]; @@ -5801,6 +5845,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let mut data = vec![2u8]; data.extend_from_slice(b"ab"); @@ -5881,6 +5926,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let text = b"hello"; let mut data = text.len().encode_var_vec(); @@ -6110,6 +6156,7 @@ mod tests { DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: Some(20), + max_bytes: None, }), vec![Value::Text("".to_string()), Value::Text("über".to_string())], ), @@ -6203,6 +6250,7 @@ mod tests { &typed_array(DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: Some(8), + max_bytes: None, })), &Value::Array(vec![Value::Text("ab".to_string())]), ); @@ -6355,6 +6403,7 @@ mod tests { DocumentPropertyType::String(StringPropertySizes { min_length: Some(3), max_length: Some(40), + max_bytes: None, }), None, 200, @@ -6370,6 +6419,7 @@ mod tests { DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }), None, 4, @@ -6382,6 +6432,7 @@ mod tests { DocumentPropertyType::String(StringPropertySizes { min_length: Some(1), max_length: Some(5000), + max_bytes: None, }), Some(1024), 1024, @@ -6559,6 +6610,7 @@ mod tests { DocumentPropertyType::String(StringPropertySizes { min_length: Some(5), max_length: Some(100), + max_bytes: None, }) ); } @@ -6922,6 +6974,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let decoded = roundtrip_encode_read(&prop, Value::Text("".to_string()), true); assert_eq!(decoded, Value::Text("".to_string())); @@ -6932,6 +6985,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: Some(100), + max_bytes: None, }); let decoded = roundtrip_encode_read(&prop, Value::Text("hello world".to_string()), true); assert_eq!(decoded, Value::Text("hello world".to_string())); @@ -6942,6 +6996,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: Some(1000), + max_bytes: None, }); let long_string = "a".repeat(500); let decoded = roundtrip_encode_read(&prop, Value::Text(long_string.clone()), true); @@ -7079,6 +7134,7 @@ mod tests { property_type: DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: Some(100), + max_bytes: None, }), required: true, transient: false, @@ -7143,6 +7199,7 @@ mod tests { property_type: DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: Some(100), + max_bytes: None, }), required: true, transient: false, @@ -7493,6 +7550,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let enc = prop .encode_value_for_tree_keys(&Value::Text("".to_string())) @@ -7508,6 +7566,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let enc = prop .encode_value_for_tree_keys(&Value::Text("test".to_string())) @@ -7874,6 +7933,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: Some(5), max_length: Some(10), + max_bytes: None, }); // Exercise several random draws for _ in 0..5 { @@ -8028,6 +8088,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: Some(7), max_length: Some(20), + max_bytes: None, }); if let Value::Text(s) = prop.random_sub_filled_value(&mut rng) { assert_eq!(s.len(), 7); @@ -8127,6 +8188,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: Some(1), max_length: Some(12), + max_bytes: None, }); if let Value::Text(s) = prop.random_filled_value(&mut rng) { assert_eq!(s.len(), 12); @@ -8260,6 +8322,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: Some(3), max_length: Some(6), + max_bytes: None, }); for _ in 0..10 { let sz = prop.random_size(&mut rng); @@ -8392,6 +8455,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); // Valid varint length but invalid UTF-8 bytes let invalid_bytes = vec![0xFFu8, 0xFEu8, 0xFDu8]; @@ -8407,6 +8471,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); // varint says 10 bytes follow, but only provide 2 let mut data = 10usize.encode_var_vec(); @@ -8561,6 +8626,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let result = prop.encode_value_ref_with_size(&Value::U64(1), true); assert!(result.is_err()); @@ -8596,6 +8662,7 @@ mod tests { property_type: DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: Some(100), + max_bytes: None, }), required: true, transient: false, @@ -8681,6 +8748,7 @@ mod tests { DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }) ); } @@ -9063,6 +9131,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: Some(3), max_length: Some(5), + max_bytes: None, }); // Boundary: exactly min and exactly max assert!(prop.value_from_string("abc").is_ok()); diff --git a/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs b/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs index 5143ef5c0e1..3e85e064161 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs @@ -156,6 +156,7 @@ impl DocumentTypeV0 { DocumentPropertyType::String(StringPropertySizes { min_length, max_length, + max_bytes: None, }) } else if random_weight < field_weights.string_weight + field_weights.integer_weight { DocumentPropertyType::I64 @@ -550,6 +551,7 @@ impl DocumentTypeV0 { DocumentPropertyType::String(StringPropertySizes { min_length, max_length, + max_bytes: None, }) } else if random_weight < field_weights.string_weight + field_weights.integer_weight { DocumentPropertyType::I64 diff --git a/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs b/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs index fa41db9badd..30f90b46e88 100644 --- a/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs @@ -1,5 +1,6 @@ use crate::data_contract::accessors::v0::DataContractV0Getters; use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::methods::DocumentTypeBasicMethods; use crate::data_contract::document_type::DocumentType; use crate::consensus::basic::document::{ @@ -90,6 +91,14 @@ impl DataContract { )); } + // Added in place at protocol version 14, inert before it: the meta-schemas there + // refuse `maxBytes`, their parser ignores it (`apply_max_bytes` is `None`, so no + // string carries a byte cap) and `validate_max_bytes` is `None`, so the check returns + // an empty result. Computed before the JSON conversion consumes `value`; reported + // only when the schema validation passes, so a schema error keeps precedence. + let max_bytes_result = + document_type.validate_max_bytes_properties(&value, platform_version)?; + let json_value = match value.try_into_validating_json() { Ok(json_value) => json_value, Err(e) => { @@ -100,7 +109,7 @@ impl DataContract { }; // Compile json schema validator if it's not yet compiled - if !validator.is_compiled(platform_version)? { + let schema_result = if !validator.is_compiled(platform_version)? { // It is normal that we get a protocol error here, since the document type is coming // from the state let root_schema = DocumentType::enrich_with_base_schema( @@ -115,10 +124,15 @@ impl DataContract { .try_to_validating_json() .map_err(ProtocolError::ValueError)?; - validator.compile_and_validate(&root_json_schema, &json_value, platform_version) + validator.compile_and_validate(&root_json_schema, &json_value, platform_version)? } else { - validator.validate(&json_value, platform_version) + validator.validate(&json_value, platform_version)? + }; + if !schema_result.is_valid() { + return Ok(schema_result); } + + Ok(max_bytes_result) } #[inline(always)] diff --git a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs index 888aca00d81..384e1d86f11 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -54,10 +54,11 @@ use crate::consensus::basic::decode::{ use crate::consensus::basic::document::{ ContestedDocumentsTemporarilyNotAllowedError, DataContractNotPresentError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, - DocumentPropertyNotDistinctError, DocumentTransitionsAreAbsentError, - DuplicateDocumentTransitionsWithIdsError, DuplicateDocumentTransitionsWithIndicesError, - InconsistentCompoundIndexDataError, InvalidDocumentTransitionActionError, - InvalidDocumentTransitionIdError, InvalidDocumentTypeError, InvalidEncryptedPropertyShapeError, + DocumentPropertyMaxBytesExceededError, DocumentPropertyNotDistinctError, + DocumentTransitionsAreAbsentError, DuplicateDocumentTransitionsWithIdsError, + DuplicateDocumentTransitionsWithIndicesError, InconsistentCompoundIndexDataError, + InvalidDocumentTransitionActionError, InvalidDocumentTransitionIdError, + InvalidDocumentTypeError, InvalidEncryptedPropertyShapeError, MaxDocumentsTransitionsExceededError, MissingDataContractIdBasicError, MissingDocumentTransitionActionError, MissingDocumentTransitionTypeError, MissingDocumentTypeError, MissingPositionsInDocumentTypePropertiesError, NonceOutOfBoundsError, @@ -91,8 +92,7 @@ use crate::consensus::basic::identity::{ }; use crate::consensus::basic::invalid_identifier_error::InvalidIdentifierError; use crate::consensus::basic::moderation_charter::{ - ModerationCharterDescriptionTooLongError, ModerationCharterMalformedFieldError, - ModerationCharterRewardSplitNotOneHundredError, + ModerationCharterMalformedFieldError, ModerationCharterRewardSplitNotOneHundredError, }; use crate::consensus::basic::state_transition::{ FeeStrategyDuplicateError, FeeStrategyEmptyError, FeeStrategyIndexOutOfBoundsError, @@ -824,8 +824,9 @@ pub enum BasicError { #[error(transparent)] ModerationCharterRewardSplitNotOneHundredError(ModerationCharterRewardSplitNotOneHundredError), + // A string over the `maxBytes` its property declares (protocol version 14). #[error(transparent)] - ModerationCharterDescriptionTooLongError(ModerationCharterDescriptionTooLongError), + DocumentPropertyMaxBytesExceededError(DocumentPropertyMaxBytesExceededError), } impl From for ConsensusError { @@ -935,7 +936,7 @@ mod tests { )), 195 ); - // Moderation charters (protocol version 14): the tail of the enum. + // Moderation charters (protocol version 14). assert_eq!( discriminant_of(BasicError::ModerationCharterMalformedFieldError( ModerationCharterMalformedFieldError::new( @@ -951,9 +952,10 @@ mod tests { )), 197 ); + // A string over its property's `maxBytes` (protocol version 14): the tail of the enum. assert_eq!( - discriminant_of(BasicError::ModerationCharterDescriptionTooLongError( - ModerationCharterDescriptionTooLongError::new(4097, 4096) + discriminant_of(BasicError::DocumentPropertyMaxBytesExceededError( + DocumentPropertyMaxBytesExceededError::new("description".to_string(), 4097, 4096) )), 198 ); diff --git a/packages/rs-dpp/src/errors/consensus/basic/document/document_property_max_bytes_exceeded_error.rs b/packages/rs-dpp/src/errors/consensus/basic/document/document_property_max_bytes_exceeded_error.rs new file mode 100644 index 00000000000..c8b2cfc03fc --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/document/document_property_max_bytes_exceeded_error.rs @@ -0,0 +1,70 @@ +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +/// A document supplied a string longer in UTF-8 bytes than the `maxBytes` its +/// type declares on the property (or on the items of a typed array of +/// strings). `maxLength` counts characters, which can be up to four bytes each; +/// `maxBytes` bounds the stored size. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("Property {property} is {byte_length} bytes in UTF-8, over its maxBytes of {max_bytes}")] +#[platform_serialize(unversioned)] +pub struct DocumentPropertyMaxBytesExceededError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + property: String, + byte_length: u32, + max_bytes: u16, +} + +impl DocumentPropertyMaxBytesExceededError { + pub fn new(property: String, byte_length: u32, max_bytes: u16) -> Self { + Self { + property, + byte_length, + max_bytes, + } + } + + /// The dotted path of the property, as the document type flattens it, with + /// the element's index (`tags[2]`) for an item of a typed array. + pub fn property(&self) -> &str { + &self.property + } + + /// The UTF-8 length of the supplied string. + pub fn byte_length(&self) -> u32 { + self.byte_length + } + + /// The declared bound. + pub fn max_bytes(&self) -> u16 { + self.max_bytes + } +} + +impl From for ConsensusError { + fn from(err: DocumentPropertyMaxBytesExceededError) -> Self { + Self::BasicError(BasicError::DocumentPropertyMaxBytesExceededError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs index 940fd3cbf34..e1364b29a39 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs @@ -2,6 +2,7 @@ mod contested_documents_temporarily_not_allowed_error; mod data_contract_not_present_error; mod document_creation_not_allowed_error; mod document_field_max_size_exceeded_error; +mod document_property_max_bytes_exceeded_error; mod document_property_not_distinct_error; mod document_transitions_are_absent_error; mod duplicate_document_transitions_with_ids_error; @@ -23,6 +24,7 @@ pub use contested_documents_temporarily_not_allowed_error::*; pub use data_contract_not_present_error::*; pub use document_creation_not_allowed_error::*; pub use document_field_max_size_exceeded_error::*; +pub use document_property_max_bytes_exceeded_error::*; pub use document_property_not_distinct_error::*; pub use document_transitions_are_absent_error::*; pub use duplicate_document_transitions_with_ids_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/mod.rs index 24207bc0246..d6829315dfd 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/mod.rs @@ -1,7 +1,5 @@ -mod moderation_charter_description_too_long_error; mod moderation_charter_malformed_field_error; mod moderation_charter_reward_split_not_one_hundred_error; -pub use moderation_charter_description_too_long_error::*; pub use moderation_charter_malformed_field_error::*; pub use moderation_charter_reward_split_not_one_hundred_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_description_too_long_error.rs b/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_description_too_long_error.rs deleted file mode 100644 index 7a9ceefb983..00000000000 --- a/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_description_too_long_error.rs +++ /dev/null @@ -1,54 +0,0 @@ -use crate::consensus::basic::BasicError; -use crate::consensus::ConsensusError; -use crate::errors::ProtocolError; -use bincode::{Decode, DecodeUntrusted, Encode}; -use platform_serialization_derive::{ - PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, -}; -use thiserror::Error; - -#[derive( - Error, - Debug, - Clone, - PartialEq, - Eq, - Encode, - Decode, - PlatformSerialize, - PlatformDeserializeTrusted, - PlatformDeserializeUntrusted, - DecodeUntrusted, -)] -#[error("The moderation charter's description is {length} bytes long, the maximum is {max_length}")] -#[platform_serialize(unversioned)] -pub struct ModerationCharterDescriptionTooLongError { - /* - - DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION - - */ - length: u64, - max_length: u16, -} - -impl ModerationCharterDescriptionTooLongError { - pub fn new(length: u64, max_length: u16) -> Self { - Self { length, max_length } - } - - /// The length of the description, in bytes of UTF-8 - pub fn length(&self) -> u64 { - self.length - } - - pub fn max_length(&self) -> u16 { - self.max_length - } -} - -impl From for ConsensusError { - fn from(err: ModerationCharterDescriptionTooLongError) -> Self { - Self::BasicError(BasicError::ModerationCharterDescriptionTooLongError(err)) - } -} diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index f569c1a6397..b4d29937acd 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -168,6 +168,7 @@ impl ErrorWithCode for BasicError { Self::ContestedDocumentsTemporarilyNotAllowedError(_) => 10418, Self::DocumentPropertyNotDistinctError(_) => 10419, Self::InvalidEncryptedPropertyShapeError(_) => 10420, + Self::DocumentPropertyMaxBytesExceededError(_) => 10421, // Token Errors: 10450-10499 Self::InvalidTokenIdError(_) => 10450, @@ -276,7 +277,6 @@ impl ErrorWithCode for BasicError { // Moderation Team Errors: 11000-11099 Self::ModerationCharterMalformedFieldError(_) => 11000, Self::ModerationCharterRewardSplitNotOneHundredError(_) => 11001, - Self::ModerationCharterDescriptionTooLongError(_) => 11002, } } } diff --git a/packages/rs-dpp/src/moderation_charter/mod.rs b/packages/rs-dpp/src/moderation_charter/mod.rs index 2a3bfdcc937..0ff87d5a770 100644 --- a/packages/rs-dpp/src/moderation_charter/mod.rs +++ b/packages/rs-dpp/src/moderation_charter/mod.rs @@ -21,10 +21,10 @@ //! members and the additions, less the removals. //! //! The schema carries almost every rule through its keywords (references, lookups, key -//! requirements, `distinctFrom`). What it cannot say is here: [`SubmittedCharter`] and -//! [`ElectedCharter`] read the documents' properties, and [`validate_submitted_charter`] adds -//! the proposal's two pure-data rules, which the path that seats a team runs. Nothing here reads -//! state. +//! requirements, `distinctFrom`, and `maxBytes` for the description's byte cap). What it +//! cannot say is here: [`SubmittedCharter`] and [`ElectedCharter`] read the documents' +//! properties, and [`validate_submitted_charter`] adds the proposal's one pure-data rule, which +//! the path that seats a team runs. Nothing here reads state. mod v0; @@ -277,10 +277,9 @@ impl SubmittedCharter { properties } - /// Checks the proposal's own rules, the two the schema cannot express: the reward split - /// sums to 100, and the description fits - /// `SystemLimits::max_moderation_charter_description_length` bytes (the schema's - /// `maxLength` counts characters). + /// Checks the proposal's own rule, the one the schema cannot express: the reward split + /// sums to 100. The description's 4096-byte cap is the schema's `maxBytes`, checked + /// wherever the document is validated. pub fn validate( &self, platform_version: &PlatformVersion, @@ -291,7 +290,7 @@ impl SubmittedCharter { .data_contract .validate_moderation_charter { - Some(0) => Ok(self.validate_v0(platform_version)), + Some(0) => Ok(self.validate_v0()), Some(version) => Err(ProtocolError::UnknownVersionMismatch { method: "SubmittedCharter::validate".to_string(), known_versions: vec![0], diff --git a/packages/rs-dpp/src/moderation_charter/tests.rs b/packages/rs-dpp/src/moderation_charter/tests.rs index 95cb9c8e73e..f9a73d0534a 100644 --- a/packages/rs-dpp/src/moderation_charter/tests.rs +++ b/packages/rs-dpp/src/moderation_charter/tests.rs @@ -110,37 +110,6 @@ fn should_refuse_a_reward_split_that_does_not_sum_to_one_hundred() { } } -#[test] -fn should_refuse_a_description_over_the_byte_limit() { - let platform_version = PlatformVersion::latest(); - let limit = platform_version - .system_limits - .max_moderation_charter_description_length as usize; - - let at_limit = SubmittedCharter { - description: "a".repeat(limit), - ..proposal() - }; - assert!( - validate_submitted_charter(&at_limit.to_document_properties(), platform_version) - .expect("validation executes") - .is_valid_with_data() - ); - - // Fewer characters than the schema's maxLength, more bytes than the consensus cap. - let over = SubmittedCharter { - description: "é".repeat(limit / 2 + 1), - ..proposal() - }; - let result = validate_submitted_charter(&over.to_document_properties(), platform_version) - .expect("validation executes"); - assert!(matches!( - first_basic_error(&result), - BasicError::ModerationCharterDescriptionTooLongError(e) - if e.length() == (limit + 2) as u64 - )); -} - #[test] fn should_refuse_a_missing_or_mistyped_property() { for field in [ diff --git a/packages/rs-dpp/src/moderation_charter/v0/mod.rs b/packages/rs-dpp/src/moderation_charter/v0/mod.rs index e81ce193c13..e02189725ae 100644 --- a/packages/rs-dpp/src/moderation_charter/v0/mod.rs +++ b/packages/rs-dpp/src/moderation_charter/v0/mod.rs @@ -1,16 +1,10 @@ -use crate::consensus::basic::moderation_charter::{ - ModerationCharterDescriptionTooLongError, ModerationCharterRewardSplitNotOneHundredError, -}; +use crate::consensus::basic::moderation_charter::ModerationCharterRewardSplitNotOneHundredError; use crate::moderation_charter::SubmittedCharter; use crate::validation::SimpleConsensusValidationResult; -use platform_version::version::PlatformVersion; impl SubmittedCharter { #[inline(always)] - pub(super) fn validate_v0( - &self, - platform_version: &PlatformVersion, - ) -> SimpleConsensusValidationResult { + pub(super) fn validate_v0(&self) -> SimpleConsensusValidationResult { if self.reward_split.total() != 100 { return SimpleConsensusValidationResult::new_with_error( ModerationCharterRewardSplitNotOneHundredError::new( @@ -22,19 +16,6 @@ impl SubmittedCharter { ); } - let max_description_length = platform_version - .system_limits - .max_moderation_charter_description_length; - if self.description.len() > max_description_length as usize { - return SimpleConsensusValidationResult::new_with_error( - ModerationCharterDescriptionTooLongError::new( - self.description.len() as u64, - max_description_length, - ) - .into(), - ); - } - SimpleConsensusValidationResult::new() } } diff --git a/packages/rs-dpp/src/system_data_contracts.rs b/packages/rs-dpp/src/system_data_contracts.rs index d8641c708b5..0ff60876166 100644 --- a/packages/rs-dpp/src/system_data_contracts.rs +++ b/packages/rs-dpp/src/system_data_contracts.rs @@ -309,6 +309,7 @@ mod app_connect_tests { #[cfg(all(test, feature = "moderation-charters-contract", feature = "validation"))] mod moderation_charters_tests { use super::*; + use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; use crate::data_contract::accessors::v0::DataContractV0Getters; use crate::data_contract::document_type::accessors::{ @@ -319,6 +320,7 @@ mod moderation_charters_tests { ContestedIndexResolution, ContractReferenceModeration, DistinctFrom, DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentType, EncryptedForRecipient, EncryptionScheme, KeyReferenceIdentityProperty, LookupKeySource, PropertyReference, + StringPropertySizes, }; use crate::data_contract::validate_document::DataContractDocumentValidationMethodsV0; use crate::document::{Document, DocumentV0Getters, DocumentV0Setters}; @@ -814,6 +816,57 @@ mod moderation_charters_tests { } } + /// The description's cap is 4096 bytes, not just 4096 characters: the schema's `maxBytes`, + /// which document validation checks after the JSON schema, so clients refuse an oversized + /// description before they broadcast it. + #[test] + fn should_refuse_a_description_over_4096_bytes_within_4096_characters() { + let contract = contract(); + assert_eq!( + document_type(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME) + .flattened_properties() + .get(property_names::DESCRIPTION) + .expect("the description") + .property_type, + DocumentPropertyType::String(StringPropertySizes { + min_length: Some(1), + max_length: Some(4096), + max_bytes: Some(4096), + }) + ); + let with_description = |description: String| { + document_with( + &contract, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + SubmittedCharter { + description, + ..proposal() + } + .to_document_properties(), + ) + }; + + // At the cap, in one-byte and in two-byte characters + for description in ["a".repeat(4096), "é".repeat(2048)] { + let document = with_description(description); + assert_eq!( + schema_validation(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, &document), + vec![] + ); + } + + // 2049 characters are within maxLength, but their 4098 bytes are over maxBytes + let document = with_description("é".repeat(2049)); + assert!(matches!( + schema_validation(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, &document) + .as_slice(), + [ConsensusError::BasicError(BasicError::DocumentPropertyMaxBytesExceededError(e))] + if e.property() == property_names::DESCRIPTION + && e.byte_length() == 4098 + && e.max_bytes() == 4096 + )); + } + /// After the election the leader adds members from the same join requests and removes /// members, and a member leaves on its own: each change is written once per member, and /// only by the one entitled to it. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/max_bytes.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/max_bytes.rs new file mode 100644 index 00000000000..ef11df52322 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/max_bytes.rs @@ -0,0 +1,421 @@ +//! End-to-end coverage for the `maxBytes` property keyword (protocol version +//! 14): a string, or each string element of a typed array, bounded by its +//! UTF-8 length. The document validation checks it after the JSON schema on +//! every create and replace; a longer value is consensus-rejected and leaves +//! the stored document untouched. + +use super::*; + +mod max_bytes_tests { + use super::*; + use crate::execution::validation::state_transition::batch::action_validation::document::document_replace_transition_action::DocumentReplaceTransitionActionValidation; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::consensus::basic::BasicError; + use dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; + use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::{DocumentBaseTransitionAction, DocumentBaseTransitionActionV0}; + use drive::state_transition_action::batch::batched_transition::document_transition::document_replace_transition_action::{DocumentReplaceTransitionAction, DocumentReplaceTransitionActionV0}; + use std::collections::{BTreeMap, BTreeSet}; + use dpp::data_contract::schema::DataContractSchemaMethodsV0; + use dpp::document::Document; + use dpp::identity::{Identity, IdentityPublicKey}; + use dpp::platform_value::platform_value; + use dpp::prelude::Identifier; + use dpp::prelude::{DataContract, IdentityNonce}; + use dpp::state_transition::StateTransition; + use dpp::tests::fixtures::get_data_contract_fixture; + use drive::util::storage_flags::StorageFlags; + use simple_signer::signer::SimpleSigner; + + /// A mutable `profile` type: a `bio` of at most 64 characters and 16 bytes, + /// and `tags`, up to four strings of at most 8 bytes each. + fn profile_schema() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": true, + "properties": { + "bio": { + "type": "string", + "maxLength": 64, + "maxBytes": 16, + "position": 0 + }, + "tags": { + "type": "array", + "maxItems": 4, + "items": { "type": "string", "maxLength": 16, "maxBytes": 8 }, + "position": 1 + } + }, + "required": ["bio", "tags"], + "additionalProperties": false + }) + } + + fn tags(tags: &[&str]) -> Value { + Value::Array( + tags.iter() + .map(|tag| Value::Text(tag.to_string())) + .collect(), + ) + } + + /// One identity and one contract whose `profile` type is the one above. + struct ProfileFixture { + platform: TempPlatform, + signer: SimpleSigner, + key: IdentityPublicKey, + identity: Identity, + contract: DataContract, + /// The identity contract nonce the next transition uses. Every + /// processed transition consumes one, including the ones that fail + /// with a paid consensus error. + next_nonce: IdentityNonce, + } + + impl ProfileFixture { + fn new() -> Self { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_initial_state_structure(); + + let (identity, signer, key) = setup_identity(&mut platform, 959, dash_to_credits!(0.5)); + + let mut contract = get_data_contract_fixture( + Some(identity.id()), + 0, + platform_version.protocol_version, + ) + .data_contract_owned(); + contract + .set_document_schema( + "profile", + profile_schema(), + true, + &mut Vec::new(), + platform_version, + ) + .expect("expected to add the profile document type"); + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the contract"); + + Self { + platform, + signer, + key, + identity, + contract, + next_nonce: 1, + } + } + + async fn create(&mut self, bio: &str, tags: Value) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let profile_type = self + .contract + .document_type_for_name("profile") + .expect("expected the profile document type"); + let mut rng = StdRng::seed_from_u64(434); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut profile = profile_type + .random_document_with_identifier_and_entropy( + &mut rng, + self.identity.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random profile"); + profile + .set_id_for_creation(profile_type, &entropy.0, self.next_nonce, platform_version) + .expect("expected to set the document id"); + profile.set("bio", Value::Text(bio.to_string())); + profile.set("tags", tags); + + let transition = BatchTransition::new_document_creation_transition_from_document( + profile, + profile_type, + entropy.0, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the create transition"); + self.next_nonce += 1; + self.process(&transition) + } + + /// Replaces `stored` with `mutate` applied and the revision bumped. + async fn replace( + &mut self, + stored: &Document, + mutate: impl FnOnce(&mut Document), + ) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let mut replacement = stored.clone(); + mutate(&mut replacement); + replacement + .increment_revision() + .expect("expected the revision to increment"); + let profile_type = self + .contract + .document_type_for_name("profile") + .expect("expected the profile document type"); + let transition = BatchTransition::new_document_replacement_transition_from_document( + replacement, + profile_type, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the replace transition"); + self.next_nonce += 1; + self.process(&transition) + } + + fn process(&self, transition: &StateTransition) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let platform_state = self.platform.state.load(); + let serialized = transition + .serialize_to_bytes() + .expect("expected the transition to serialize"); + let transaction = self.platform.drive.grove.start_transaction(); + let processing_result = self + .platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the state transition"); + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + processing_result.into_execution_results().remove(0) + } + + /// The stored profiles, read back from Drive. + fn stored_profiles(&self) -> Vec { + let platform_version = PlatformVersion::latest(); + let query = DriveDocumentQuery::from_sql_expr( + "select * from profile", + &self.contract, + Some(&self.platform.config.drive), + platform_version, + ) + .expect("expected a document query"); + self.platform + .drive + .query_documents(query, None, false, None, None) + .expect("expected a query result") + .documents() + .to_vec() + } + } + + fn expect_max_bytes_error( + result: StateTransitionExecutionResult, + property: &str, + byte_length: u32, + max_bytes: u16, + ) { + let StateTransitionExecutionResult::PaidConsensusError { error, .. } = result else { + panic!("expected a paid consensus error, got {result:?}"); + }; + assert_matches!( + error, + ConsensusError::BasicError(BasicError::DocumentPropertyMaxBytesExceededError(e)) + if e.property() == property + && e.byte_length() == byte_length + && e.max_bytes() == max_bytes + ); + } + + #[tokio::test] + async fn should_create_a_document_within_its_byte_caps() { + let mut fixture = ProfileFixture::new(); + + // Sixteen bytes in eight two-byte characters, and tags of eight bytes at most + let result = fixture.create("éééééééé", tags(&["éééé", "short"])).await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_profiles().len(), 1); + } + + /// Nine two-byte characters are far inside `maxLength`, so the JSON schema + /// accepts them; their 18 bytes are over `maxBytes`. + #[tokio::test] + async fn should_refuse_a_string_over_its_max_bytes_within_its_max_length() { + let mut fixture = ProfileFixture::new(); + + let result = fixture.create("ééééééééé", tags(&[])).await; + + expect_max_bytes_error(result, "bio", 18, 16); + assert!(fixture.stored_profiles().is_empty()); + } + + /// Each element of a typed array of strings is bounded on its own, and the + /// refusal names the element. + #[tokio::test] + async fn should_refuse_a_typed_array_element_over_its_max_bytes() { + let mut fixture = ProfileFixture::new(); + + let result = fixture + .create("hello", tags(&["ok", "fine", "ééééé"])) + .await; + + expect_max_bytes_error(result, "tags[2]", 10, 8); + assert!(fixture.stored_profiles().is_empty()); + } + + #[tokio::test] + async fn should_refuse_a_replace_over_a_byte_cap() { + let mut fixture = ProfileFixture::new(); + let result = fixture.create("hello", tags(&["a"])).await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let stored = fixture.stored_profiles().remove(0); + + let result = fixture + .replace(&stored, |profile| { + profile.set("bio", Value::Text("ééééééééé".to_string())) + }) + .await; + expect_max_bytes_error(result, "bio", 18, 16); + + let result = fixture + .replace(&stored, |profile| profile.set("tags", tags(&["ééééé"]))) + .await; + expect_max_bytes_error(result, "tags[0]", 10, 8); + + let after = fixture.stored_profiles(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].get("bio"), stored.get("bio")); + assert_eq!(after[0].get("tags"), stored.get("tags")); + + // A replace within both caps still goes through + let result = fixture + .replace(&stored, |profile| { + profile.set("bio", Value::Text("hé".to_string())); + profile.set("tags", tags(&["éééé"])); + }) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + /// The replace structure dispatcher on both sides of the gate: the document + /// validation it runs gained the check in place, so at protocol version 13 + /// it must still accept the action (the dpp gate is `None` there), and at + /// 14 refuse it. The action is built by hand the way the transformer would + /// build it, against the contract as Drive hands it back. + #[test] + fn should_not_check_the_byte_cap_on_replace_before_protocol_version_14() { + let platform_version = PlatformVersion::latest(); + let fixture = ProfileFixture::new(); + let owner_id = fixture.identity.id(); + + let (_, contract_fetch_info) = fixture + .platform + .drive + .get_contract_with_fetch_info_and_fee( + fixture.contract.id().to_buffer(), + None, + false, + None, + platform_version, + ) + .expect("expected to fetch the contract"); + let contract_fetch_info = contract_fetch_info.expect("the contract is in state"); + + let action = |bio: &str| { + DocumentReplaceTransitionAction::V0(DocumentReplaceTransitionActionV0 { + base: DocumentBaseTransitionAction::V0(DocumentBaseTransitionActionV0 { + id: Identifier::from([0xAA; 32]), + identity_contract_nonce: 1, + document_type_name: "profile".to_string(), + data_contract: contract_fetch_info.clone(), + token_cost: None, + gas_fees_paid_by: GasFeesPaidBy::default(), + contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, + }), + revision: 2, + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + data: BTreeMap::from([ + ("bio".to_string(), Value::Text(bio.to_string())), + ("tags".to_string(), tags(&[])), + ]), + changed_data_fields: BTreeSet::new(), + added_data_fields: BTreeSet::new(), + removed_identifier_fields: BTreeMap::new(), + stored_changed_values: BTreeMap::new(), + creator_id: None, + }) + }; + let platform_version_13 = + PlatformVersion::get(13).expect("platform version 13 should exist"); + + let before = action("ééééééééé") + .validate_structure(owner_id, platform_version_13) + .expect("structure validation should run"); + assert!( + before.is_valid(), + "the document validation must not check the byte cap before 14: {:?}", + before.errors + ); + + let at = action("ééééééééé") + .validate_structure(owner_id, platform_version) + .expect("structure validation should run"); + assert_matches!( + at.errors.as_slice(), + [ConsensusError::BasicError(BasicError::DocumentPropertyMaxBytesExceededError(e))] + if e.property() == "bio" && e.byte_length() == 18 + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs index bcbd7f6419c..a2bde5a7d88 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs @@ -12,6 +12,7 @@ mod index_only; mod keep_history; mod list_element_reference; mod lookup_reference; +mod max_bytes; mod nft; mod owner_balance_proof; mod owner_reference; diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index 0d231e38e71..acc2b667abd 100644 --- a/packages/rs-drive/src/query/conditions.rs +++ b/packages/rs-drive/src/query/conditions.rs @@ -1519,6 +1519,7 @@ fn meta_field_property_type(field: &str) -> Option { dpp::data_contract::document_type::StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }, )), _ => None, @@ -3527,6 +3528,7 @@ mod tests { let ty = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let ops = allowed_ops_for_type(&ty); assert!(ops.contains(&super::StartsWith)); @@ -3596,6 +3598,7 @@ mod tests { let str_ty = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); assert!(WhereOperator::StartsWith.value_shape_ok(&Value::Text("abc".into()), &str_ty)); assert!(!WhereOperator::StartsWith.value_shape_ok(&Value::I64(1), &str_ty)); @@ -3623,6 +3626,7 @@ mod tests { let str_ty = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); assert!(WhereOperator::LessThan.value_shape_ok(&Value::Text("a".into()), &str_ty)); assert!(!WhereOperator::LessThan.value_shape_ok(&Value::I64(1), &str_ty)); @@ -4204,6 +4208,7 @@ mod tests { let str_ty = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let good = Value::Array(vec![Value::Text("aaa".into()), Value::Text("zzz".into())]); diff --git a/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs b/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs index 60267028861..f884fe9e38c 100644 --- a/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs +++ b/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs @@ -1512,6 +1512,42 @@ pub static KEYWORD_COMPATIBILITY_RULES: Lazy = Laz ], }, ), + // `maxBytes` (the most UTF-8 bytes a string may take) moves like + // `maxLength`: raising or dropping the bound keeps every stored document + // valid, adding or lowering it would not. + ( + "maxBytes", + CompatibilityRules { + allow_addition: false, + allow_removal: true, + allow_replacement_callback: U64_BIGGER_CALLBACK.clone(), + subschema_levels_depth: None, + inner: None, + #[cfg(any(test, feature = "examples"))] + examples: vec![ + ( + json!({}), + json!({ "maxBytes": 1 }), + Some(JsonSchemaChange::Add(AddOperation { + path: "/maxBytes".to_string(), + value: json!(1), + })), + ) + .into(), + (json!({ "maxBytes": 1 }), json!({}), None).into(), + (json!({ "maxBytes": 1 }), json!({ "maxBytes": 2 }), None).into(), + ( + json!({ "maxBytes": 2 }), + json!({ "maxBytes": 1 }), + Some(JsonSchemaChange::Replace(ReplaceOperation { + path: "/maxBytes".to_string(), + value: json!(1), + })), + ) + .into(), + ], + }, + ), ( "$defs", CompatibilityRules { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs index 2f55e2dd4c6..0c01018dbe8 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs @@ -84,6 +84,11 @@ pub struct DocumentTypeMethodVersions { /// that predate the keyword: the method returns an empty result there, so the /// shipped create and replace structure validations that call it are inert. pub validate_encrypted_property_shapes: OptionalFeatureVersion, + /// `validate_max_bytes_properties`: refuses a document supplying a string longer in + /// UTF-8 bytes than the `maxBytes` its property declares. `None` on versions that + /// predate the keyword: the method returns an empty result there, so the shipped + /// document validation that calls it is inert. + pub validate_max_bytes: OptionalFeatureVersion, } #[derive(Clone, Debug, Default)] @@ -113,6 +118,11 @@ pub struct DocumentTypeSchemaVersions { /// `None` on versions that predate the keyword: they ignore it entirely, /// exactly as they parsed before it existed. pub apply_encrypted_for: OptionalFeatureVersion, + /// Folds the `maxBytes` keyword (the most UTF-8 bytes a string property, or + /// each string element of a typed array, may hold) into the string's + /// `StringPropertySizes`. `None` on versions that predate the keyword: they + /// ignore it entirely, exactly as they parsed before it existed. + pub apply_max_bytes: OptionalFeatureVersion, /// Parses a typed array property (`type: "array"` with an `items` /// element schema instead of `byteArray`). `None` on versions that /// predate typed arrays: they leave such a property to the scalar diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs index a8d3161f6c4..1489be6adc6 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs @@ -47,6 +47,7 @@ pub const CONTRACT_VERSIONS_V1: DPPContractVersions = DPPContractVersions { apply_required_since: None, apply_distinct_from: None, apply_encrypted_for: None, + apply_max_bytes: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -67,6 +68,7 @@ pub const CONTRACT_VERSIONS_V1: DPPContractVersions = DPPContractVersions { deserialize_value_for_key: 0, validate_distinct_from: None, validate_encrypted_property_shapes: None, + validate_max_bytes: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs index 5f35727e72f..e857af08eae 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs @@ -47,6 +47,7 @@ pub const CONTRACT_VERSIONS_V2: DPPContractVersions = DPPContractVersions { apply_required_since: None, apply_distinct_from: None, apply_encrypted_for: None, + apply_max_bytes: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -67,6 +68,7 @@ pub const CONTRACT_VERSIONS_V2: DPPContractVersions = DPPContractVersions { deserialize_value_for_key: 0, validate_distinct_from: None, validate_encrypted_property_shapes: None, + validate_max_bytes: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs index 0c21c7188d4..dd3bcc8f42d 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs @@ -49,6 +49,7 @@ pub const CONTRACT_VERSIONS_V3: DPPContractVersions = DPPContractVersions { apply_required_since: None, apply_distinct_from: None, apply_encrypted_for: None, + apply_max_bytes: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -69,6 +70,7 @@ pub const CONTRACT_VERSIONS_V3: DPPContractVersions = DPPContractVersions { deserialize_value_for_key: 0, validate_distinct_from: None, validate_encrypted_property_shapes: None, + validate_max_bytes: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs index 4e381e26d00..8eedce707de 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs @@ -49,6 +49,7 @@ pub const CONTRACT_VERSIONS_V4: DPPContractVersions = DPPContractVersions { apply_required_since: None, apply_distinct_from: None, apply_encrypted_for: None, + apply_max_bytes: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -69,6 +70,7 @@ pub const CONTRACT_VERSIONS_V4: DPPContractVersions = DPPContractVersions { deserialize_value_for_key: 0, validate_distinct_from: None, validate_encrypted_property_shapes: None, + validate_max_bytes: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs index afc06a4c41d..225ba03bf6b 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs @@ -51,6 +51,7 @@ pub const CONTRACT_VERSIONS_V5: DPPContractVersions = DPPContractVersions { apply_required_since: None, apply_distinct_from: None, apply_encrypted_for: None, + apply_max_bytes: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -71,6 +72,7 @@ pub const CONTRACT_VERSIONS_V5: DPPContractVersions = DPPContractVersions { deserialize_value_for_key: 0, validate_distinct_from: None, validate_encrypted_property_shapes: None, + validate_max_bytes: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs index a43ce0d7464..59df9b91696 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs @@ -84,6 +84,7 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { apply_required_since: Some(0), // changed: the meta-schema v3 `requiredSince` keyword (contract version a property is required from) is parsed onto the property; None before this version means the keyword is ignored, as it was before it existed apply_distinct_from: Some(0), // changed: the meta-schema v3 `distinctFrom` keyword (an identifier property whose value must differ from a named sibling property or the document's `$ownerId`) is parsed onto the property; None before this version means the keyword is ignored, as it was before it existed apply_encrypted_for: Some(0), // changed: the meta-schema v3 `encryptedFor` keyword (recipient, key ids and scheme of a byte array property's ciphertext) is parsed onto the property and its named properties are checked at registration; None before this version means the keyword is ignored, as it was before it existed + apply_max_bytes: Some(0), // changed: the meta-schema v3 `maxBytes` keyword (the most UTF-8 bytes a string property, or each string element of a typed array, may hold) is folded into the string's `StringPropertySizes`; None before this version means the keyword is ignored, as it was before it existed parse_typed_array: Some(0), // changed: a meta-schema v3 typed array (`type: "array"` with an `items` element schema) parses to `DocumentPropertyType::TypedArray`; None before this version leaves it to the scalar parser, which refuses an array that is not a byte array validate_max_depth: 0, max_depth: 256, @@ -115,6 +116,7 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { deserialize_value_for_key: 0, validate_distinct_from: Some(0), // changed: `validate_distinct_from_properties` refuses a document whose `distinctFrom` property equals what it must differ from (DocumentPropertyNotDistinctError, 10419); None before this version, where no property can carry the keyword validate_encrypted_property_shapes: Some(0), // changed: refuses an `encryptedFor` property whose bytes are not the shape its scheme produces; None before this version returns an empty result + validate_max_bytes: Some(0), // changed: refuses a string longer in UTF-8 bytes than its property's `maxBytes` (DocumentPropertyMaxBytesExceededError, 10421); None before this version returns an empty result }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/mocks/v2_test.rs b/packages/rs-platform-version/src/version/mocks/v2_test.rs index 30afaa9e2b4..8de307739c9 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -599,7 +599,6 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { min_contract_moderation_challenge_cool_down_seconds: 1_209_600, max_contract_moderation_challenge_cool_down_seconds: 94_608_000, contract_document_restore_window_ms: 604_800_000, - max_moderation_charter_description_length: 4096, max_contract_moderation_added_moderators: 15, max_token_redemption_cycles: 128, max_shielded_transition_actions: 16, diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index 8db45633aed..0c6935e38f5 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -179,11 +179,6 @@ pub struct SystemLimits { /// action): a week. Read by the `ContractUserModeration` state validation v0 (protocol /// version 14) and never reached before. pub contract_document_restore_window_ms: u64, - /// Maximum length, in bytes of UTF-8, of a moderation charter's description. Read by - /// `SubmittedCharter::validate` v0 (protocol version 14) and never reached before; the - /// charter schema pins the same number as the description's `maxLength`, which the JSON - /// schema validator counts in characters, so the byte cap is this check's. - pub max_moderation_charter_description_length: u16, /// Most members an elected moderation declaration may let a seated team's leader add /// after the election (`maxAddedModerators`). Read by the declaration's validation /// (protocol version 14) and never reached before. diff --git a/packages/rs-platform-version/src/version/system_limits/v1.rs b/packages/rs-platform-version/src/version/system_limits/v1.rs index 48675f9c5e1..014f0b099af 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -60,7 +60,6 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days contract_document_restore_window_ms: 604_800_000, // 7 days - max_moderation_charter_description_length: 4096, max_contract_moderation_added_moderators: 15, max_token_redemption_cycles: 128, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on diff --git a/packages/rs-platform-version/src/version/system_limits/v2.rs b/packages/rs-platform-version/src/version/system_limits/v2.rs index 09cb333eb71..66e1a2185d6 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -41,7 +41,6 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days contract_document_restore_window_ms: 604_800_000, // 7 days - max_moderation_charter_description_length: 4096, max_contract_moderation_added_moderators: 15, max_token_redemption_cycles: 128, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on diff --git a/packages/rs-platform-version/src/version/system_limits/v3.rs b/packages/rs-platform-version/src/version/system_limits/v3.rs index ef2f8c925ae..632c475cc04 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -43,7 +43,6 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days contract_document_restore_window_ms: 604_800_000, // 7 days - max_moderation_charter_description_length: 4096, max_contract_moderation_added_moderators: 15, max_token_redemption_cycles: 128, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on diff --git a/packages/rs-platform-version/src/version/system_limits/v4.rs b/packages/rs-platform-version/src/version/system_limits/v4.rs index 0d19a7aeb54..64b39b7801a 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -63,11 +63,10 @@ use crate::version::system_limits::SystemLimits; /// most 4 operands (`max_reference_operands`) and they nest at most 4 combinators deep /// (`max_reference_expression_depth`), both backfilled into the earlier tables, whose parsers /// never read them; every leaf counts against `max_references_per_document`. -/// * Moderation charters (protocol version 14): a charter's description is at most 4096 -/// bytes (`max_moderation_charter_description_length`), and an elected moderation -/// declaration lets a seated team's leader add at most 15 members -/// (`max_contract_moderation_added_moderators`); both joined this table in place while -/// protocol version 14 was unreleased. +/// * Moderation charters (protocol version 14): an elected moderation declaration lets a +/// seated team's leader add at most 15 members (`max_contract_moderation_added_moderators`), +/// which joined this table in place while protocol version 14 was unreleased. A charter's +/// description cap is the charter schema's own `maxBytes`, not a limit here. pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { estimated_contract_max_serialized_size: 16384, max_field_value_size: 5120, //5 KiB @@ -105,7 +104,6 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days contract_document_restore_window_ms: 604_800_000, // 7 days - max_moderation_charter_description_length: 4096, max_contract_moderation_added_moderators: 15, max_token_redemption_cycles: 128, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 9a1a7e1ab0e..2f9f81d3334 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -969,16 +969,36 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// its target. `SYSTEM_DATA_CONTRACT_VERSIONS_V3` registers it /// (`moderation_charters: 1`), and /// `DPP_VALIDATION_VERSIONS_V5.validate_moderation_charter = Some(0)` turns -/// on the pure-data rules the seating path will run on a proposal: its -/// reward split sums to 100 and its description fits -/// `SystemLimits::max_moderation_charter_description_length` bytes (basic -/// errors 11000 to 11002). Genesis registers it on chains born at this +/// on the pure-data rule the seating path will run on a proposal: its +/// reward split sums to 100 (basic errors 11000 and 11001). Its description +/// fits 4096 bytes through the schema's own `maxBytes` (item 38), which +/// every document validation checks. Genesis registers it on chains born at this /// version (`create_genesis_state` v1, behind the app-connect branch), /// `transition_to_version_14` inserts it on upgrade, and the Drive system /// contract cache serves it from this version /// (`MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION`). Seating a /// winning team comes in a later pull request. /// +/// 38. **`maxBytes` on strings**: a property keyword for the bound plain JSON +/// Schema cannot count, the most UTF-8 bytes a string may take +/// (`maxLength` counts characters, which are up to four bytes each). It +/// goes on a string property, or on the `items` of a typed array of +/// strings where it bounds every element, and is 1 to 65535 and no lower +/// than `minLength`, checked at registration. Meta-schema v3 admits it and +/// `apply_max_bytes` 0 folds it into `StringPropertySizes::max_bytes`, so +/// `max_byte_size`, `max_size` and random documents respect it. The +/// document validation (`DataContract::validate_document_properties` 0, +/// extended in place, inert before this version) calls +/// `validate_max_bytes_properties` (`validate_max_bytes` 0, `None` before +/// this version) after the JSON schema, on every create and replace and in +/// every client that validates a document, and refuses a longer value with +/// `DocumentPropertyMaxBytesExceededError` (10421, naming the element as +/// `tags[2]` for an item). On update it moves like `maxLength`: it may be +/// raised or removed, not added or lowered. The moderation charters +/// contract (item 37) declares it on the proposal's description, replacing +/// the charter-specific description check, its error 11002 and +/// `SystemLimits::max_moderation_charter_description_length`. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 00b794efe42..0266de44da6 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -67,7 +67,7 @@ use dpp::consensus::state::data_trigger::DataTriggerError::{ }; use wasm_bindgen::{JsError, JsValue}; use dpp::consensus::basic::data_contract::{ContestedUniqueIndexOnMutableDocumentTypeError, DataContractInvalidRequiredFieldsUpdateError, ContestedUniqueIndexWithUniqueIndexError, DataContractTokenConfigurationUpdateError, DecimalsOverLimitError, DuplicateKeywordsError, GroupExceedsMaxMembersError, GroupHasTooFewMembersError, GroupMemberHasPowerOfZeroError, GroupMemberHasPowerOverLimitError, GroupNonUnilateralMemberPowerHasLessThanRequiredPowerError, GroupPositionDoesNotExistError, GroupRequiredPowerIsInvalidError, GroupTotalPowerLessThanRequiredError, InvalidDescriptionLengthError, InvalidDocumentTypeRequiredSecurityLevelError, InvalidKeywordCharacterError, InvalidKeywordLengthError, InvalidTokenBaseSupplyError, InvalidTokenDistributionFunctionDivideByZeroError, InvalidTokenDistributionFunctionIncoherenceError, InvalidTokenDistributionFunctionInvalidParameterError, InvalidTokenDistributionFunctionInvalidParameterTupleError, InvalidTokenLanguageCodeError, InvalidTokenNameCharacterError, InvalidTokenNameLengthError, MainGroupIsNotDefinedError, NewTokensDestinationIdentityOptionRequiredError, NonContiguousContractGroupPositionsError, NonContiguousContractTokenPositionsError, PreProgrammedDistributionAmountOverLimitError, RedundantDocumentPaidForByTokenWithContractId, TokenPaymentByBurningOnlyAllowedOnInternalTokenError, TooManyKeywordsError, UnknownDocumentActionTokenEffectError, UnknownDocumentCreationRestrictionModeError, UnknownGasFeesPaidByError, UnknownSecurityLevelError, UnknownStorageKeyRequirementsError, UnknownTradeModeError, UnknownTransferableTypeError}; -use dpp::consensus::basic::document::{ContestedDocumentsTemporarilyNotAllowedError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, DocumentPropertyNotDistinctError, InvalidEncryptedPropertyShapeError, MaxDocumentsTransitionsExceededError, MissingPositionsInDocumentTypePropertiesError}; +use dpp::consensus::basic::document::{ContestedDocumentsTemporarilyNotAllowedError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, DocumentPropertyMaxBytesExceededError, DocumentPropertyNotDistinctError, InvalidEncryptedPropertyShapeError, MaxDocumentsTransitionsExceededError, MissingPositionsInDocumentTypePropertiesError}; use dpp::consensus::basic::group::GroupActionNotAllowedOnTransitionError; use dpp::consensus::basic::identity::{DataContractBoundsNotPresentError, DisablingKeyIdAlsoBeingAddedInSameTransitionError, InvalidIdentityCreditWithdrawalTransitionAmountError, InvalidIdentityUpdateTransitionDisableKeysError, InvalidIdentityUpdateTransitionEmptyError, InvalidKeyPurposeForContractBoundsError, TooManyMasterPublicKeyError, WithdrawalOutputScriptNotAllowedWhenSigningWithOwnerKeyError}; use dpp::consensus::basic::overflow_error::OverflowError; @@ -96,8 +96,7 @@ use dpp::consensus::basic::contract_moderation::{ InvalidContractModerationConfigError, }; use dpp::consensus::basic::moderation_charter::{ - ModerationCharterDescriptionTooLongError, ModerationCharterMalformedFieldError, - ModerationCharterRewardSplitNotOneHundredError, + ModerationCharterMalformedFieldError, ModerationCharterRewardSplitNotOneHundredError, }; use dpp::consensus::state::contract_moderation::{ ContractFeeClaimNotAllowedError, ContractFeesAlreadyClaimedThisEpochError, @@ -1286,15 +1285,15 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::ModerationCharterRewardSplitNotOneHundredError(e) => { generic_consensus_error!(ModerationCharterRewardSplitNotOneHundredError, e).into() } - BasicError::ModerationCharterDescriptionTooLongError(e) => { - generic_consensus_error!(ModerationCharterDescriptionTooLongError, e).into() - } BasicError::InvalidContractModerationReasonDocumentsError(e) => { generic_consensus_error!(InvalidContractModerationReasonDocumentsError, e).into() } BasicError::InvalidEncryptedPropertyShapeError(e) => { generic_consensus_error!(InvalidEncryptedPropertyShapeError, e).into() } + BasicError::DocumentPropertyMaxBytesExceededError(e) => { + generic_consensus_error!(DocumentPropertyMaxBytesExceededError, e).into() + } } } diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index 9a89473646c..c7d3e0f3bfc 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -201,6 +201,41 @@ impl DocumentEncryptionErrorCodeWasm { } } +/// Consensus error codes emitted by the `maxBytes` check, which runs from +/// protocol version 14 onward wherever a document is validated, on every +/// create and replace included. +/// +/// Branch on an error's `code` against these instead of matching its +/// message: +/// +/// ```js +/// try { +/// await sdk.documents.create({ document, identityKey, signer }); +/// } catch (e) { +/// if (e.code === DocumentMaxBytesErrorCode.MaxBytesExceeded) { +/// // a string is longer in UTF-8 bytes than its property's maxBytes +/// } +/// } +/// ``` +#[wasm_bindgen(js_name = "DocumentMaxBytesErrorCode")] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum DocumentMaxBytesErrorCodeWasm { + /// A string, or an element of a typed array of strings, is longer in + /// UTF-8 bytes than the `maxBytes` its property declares. `maxLength` + /// counts characters, which are up to four bytes each. + MaxBytesExceeded = 10421, +} + +impl DocumentMaxBytesErrorCodeWasm { + /// The maxBytes error a code names, or `None` for any other code. + fn from_code(code: u32) -> Option { + match code { + 10421 => Some(Self::MaxBytesExceeded), + _ => None, + } + } +} + #[wasm_bindgen(js_name = "ConsensusError")] pub struct ConsensusErrorWasm(ConsensusError); @@ -254,6 +289,11 @@ impl ConsensusErrorWasm { pub fn document_encryption_error_code(&self) -> Option { DocumentEncryptionErrorCodeWasm::from_code(self.0.code()) } + /// The maxBytes error this is, or `undefined` when it is not code 10421. + #[wasm_bindgen(getter = "documentMaxBytesErrorCode")] + pub fn document_max_bytes_error_code(&self) -> Option { + DocumentMaxBytesErrorCodeWasm::from_code(self.0.code()) + } } impl_wasm_type_info!(ConsensusErrorWasm, ConsensusError); @@ -375,6 +415,29 @@ mod tests { assert_eq!(DocumentEncryptionErrorCodeWasm::from_code(10419), None); } + /// Built from the real DPP error rather than a code literal, like the + /// encryption test above. + #[test] + fn should_mirror_the_dpp_error_in_the_max_bytes_error_code() { + use dpp::consensus::basic::BasicError; + use dpp::consensus::basic::document::DocumentPropertyMaxBytesExceededError; + + let error = ConsensusError::from(BasicError::DocumentPropertyMaxBytesExceededError( + DocumentPropertyMaxBytesExceededError::new("description".to_string(), 4098, 4096), + )); + + assert_eq!( + DocumentMaxBytesErrorCodeWasm::MaxBytesExceeded as u32, + error.code() + ); + assert_eq!( + ConsensusErrorWasm(error).document_max_bytes_error_code(), + Some(DocumentMaxBytesErrorCodeWasm::MaxBytesExceeded) + ); + // A neighbouring code is not claimed. + assert_eq!(DocumentMaxBytesErrorCodeWasm::from_code(10420), None); + } + /// The six reference-validation errors, paired with the JS enum variant /// each is advertised to be. /// diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts index 6d15eca3f81..33c96db7fe8 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts @@ -499,10 +499,10 @@ describe('DataContract — refersTo declarations (v14)', () => { additionalProperties: false, }, }; - const buildListElementContract = (schemas: object) => new wasm.DataContract({ + const buildListElementContract = (documentSchemas: object) => new wasm.DataContract({ ownerId, identityNonce: BigInt(2), - schemas, + schemas: documentSchemas, definitions: null, fullValidation: true, platformVersion: new PlatformVersion(14),