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 new file mode 100644 index 0000000000..2117ce8dff --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs @@ -0,0 +1,1712 @@ +//! Helpers shared by every generation of `DocumentTypeRef::validate_update` +//! (`v0`, `v1`, …). Only the parts of the update-validation flow that differ +//! between generations live in the per-version modules; the config, byte-array +//! encoding and JSON-schema compatibility checks below are generation +//! independent. + +use crate::consensus::basic::data_contract::IncompatibleDocumentTypeSchemaError; +use crate::consensus::state::data_contract::document_type_update_error::DocumentTypeUpdateError; +use crate::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, +}; +use crate::data_contract::document_type::property::{ByteArrayPropertySizes, DocumentPropertyType}; +use crate::data_contract::document_type::schema::validate_schema_compatibility; +use crate::data_contract::document_type::DocumentTypeRef; +use crate::data_contract::errors::DataContractError; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +impl DocumentTypeRef<'_> { + /// A byte array property whose `minItems == maxItems` is serialized as raw, + /// fixed-length bytes with no length prefix; any other size bounds make it + /// serialized with a variable-length (varint) length prefix. Crossing that + /// boundary -- or changing the fixed length itself -- silently changes the + /// on-disk layout of every already-stored document, so re-decoding old bytes + /// against the new type misreads them. JSON-schema compatibility treats + /// widening/removing `maxItems` as compatible, so this layout invariant must + /// be enforced separately. Runs before `validate_schema` so it cannot be + /// bypassed by a JSON-schema-compatible widening. + pub(super) fn validate_byte_array_encoding_stability( + &self, + new_document_type: DocumentTypeRef, + ) -> SimpleConsensusValidationResult { + // Mirror the encoder/decoder exactly (see `encode_value_ref_with_size`): + // the raw, no-length-prefix path is used ONLY when BOTH bounds are present + // and equal. Any other shape -- including an omitted `minItems` (`None`) -- + // is varint length-prefixed, so an implicit `minItems: 0` must NOT be + // treated as fixed-length here or this guard would diverge from the actual + // on-disk layout. `Some(n)` => fixed raw encoding of length `n`; `None` => + // variable (varint length-prefixed) encoding. + fn fixed_length(sizes: &ByteArrayPropertySizes) -> Option { + match (sizes.min_size, sizes.max_size) { + (Some(min), Some(max)) if min == max => Some(min), + _ => None, + } + } + + let new_properties = new_document_type.flattened_properties(); + + for (path, old_property) in self.flattened_properties() { + let DocumentPropertyType::ByteArray(old_sizes) = &old_property.property_type else { + continue; + }; + + let Some(new_property) = new_properties.get(path) else { + continue; + }; + + let DocumentPropertyType::ByteArray(new_sizes) = &new_property.property_type else { + continue; + }; + + if fixed_length(old_sizes) != fixed_length(new_sizes) { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change the byte array encoding of property \ + '{}': changing its size bounds from (minItems: {:?}, maxItems: {:?}) \ + to (minItems: {:?}, maxItems: {:?}) alters the on-disk layout of \ + existing documents", + path, + old_sizes.min_size, + old_sizes.max_size, + new_sizes.min_size, + new_sizes.max_size, + ), + ) + .into(), + ); + } + } + + SimpleConsensusValidationResult::new() + } + + pub(super) fn validate_config( + &self, + new_document_type: DocumentTypeRef, + ) -> SimpleConsensusValidationResult { + if new_document_type.creation_restriction_mode() != self.creation_restriction_mode() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change creation restriction mode: changing from {} to {}", + self.creation_restriction_mode(), + new_document_type.creation_restriction_mode() + ), + ) + .into(), + ); + } + + if new_document_type.trade_mode() != self.trade_mode() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change trade mode: changing from {} to {}", + self.trade_mode(), + new_document_type.trade_mode() + ), + ) + .into(), + ); + } + + if new_document_type.documents_transferable() != self.documents_transferable() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether its documents are transferable: changing from {} to {}", + self.documents_transferable(), + new_document_type.documents_transferable() + ), + ) + .into(), + ); + } + + if new_document_type.documents_can_be_deleted() != self.documents_can_be_deleted() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether its documents can be deleted: changing from {} to {}", + self.documents_can_be_deleted(), + new_document_type.documents_can_be_deleted() + ), + ) + .into(), + ); + } + + if new_document_type.documents_keep_history() != self.documents_keep_history() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether it keeps history: changing from {} to {}", + self.documents_keep_history(), + new_document_type.documents_keep_history() + ), + ) + .into(), + ); + } + + if new_document_type.documents_keep_transfer_history() + != self.documents_keep_transfer_history() + { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether it keeps transfer history: changing from {} to {}", + self.documents_keep_transfer_history(), + new_document_type.documents_keep_transfer_history() + ), + ) + .into(), + ); + } + + if new_document_type.documents_keep_purchase_history() + != self.documents_keep_purchase_history() + { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether it keeps purchase history: changing from {} to {}", + self.documents_keep_purchase_history(), + new_document_type.documents_keep_purchase_history() + ), + ) + .into(), + ); + } + + if new_document_type.documents_keep_pricing_history() + != self.documents_keep_pricing_history() + { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether it keeps pricing history: changing from {} to {}", + self.documents_keep_pricing_history(), + new_document_type.documents_keep_pricing_history() + ), + ) + .into(), + ); + } + + if new_document_type.documents_mutable() != self.documents_mutable() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether its documents are mutable: changing from {} to {}", + self.documents_mutable(), + new_document_type.documents_mutable() + ), + ) + .into(), + ); + } + + if new_document_type.requires_identity_encryption_bounded_key() + != self.requires_identity_encryption_bounded_key() + { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether it required an identity encryption bounded key: changing from {:?} to {:?}", + self.requires_identity_encryption_bounded_key(), + new_document_type.requires_identity_encryption_bounded_key() + ), + ) + .into(), + ); + } + + if new_document_type.requires_identity_decryption_bounded_key() + != self.requires_identity_decryption_bounded_key() + { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether it required an identity decryption bounded key: changing from {:?} to {:?}", + self.requires_identity_decryption_bounded_key(), + new_document_type.requires_identity_decryption_bounded_key() + ), + ) + .into(), + ); + } + + if new_document_type.security_level_requirement() != self.security_level_requirement() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change the security level requirement for its updates: changing from {:?} to {:?}", + self.security_level_requirement(), + new_document_type.security_level_requirement() + ), + ) + .into(), + ); + } + + if new_document_type.documents_countable() != self.documents_countable() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether its documents are countable: changing from {} to {}", + self.documents_countable(), + new_document_type.documents_countable() + ), + ) + .into(), + ); + } + + if new_document_type.range_countable() != self.range_countable() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether it is range countable: changing from {} to {}", + self.range_countable(), + new_document_type.range_countable() + ), + ) + .into(), + ); + } + + // Sum-tree immutability — parallels the count flags above. + // Two checks: (1) whether the doctype is summable at all (the + // presence/absence of `documents_summable`), and (2) the *name* of + // the summed property. Changing either invalidates every on-disk + // sum contribution because grovedb's sum trees aggregate `i64` + // per merk node — a renamed property would silently double-count + // or under-count depending on which document field gets read. + if new_document_type.documents_summable() != self.documents_summable() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether or how its documents are summable: changing from {:?} to {:?}", + self.documents_summable(), + new_document_type.documents_summable() + ), + ) + .into(), + ); + } + + if new_document_type.range_summable() != self.range_summable() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether it is range summable: changing from {} to {}", + self.range_summable(), + new_document_type.range_summable() + ), + ) + .into(), + ); + } + + SimpleConsensusValidationResult::new() + } + + pub(super) fn validate_schema( + &self, + new_document_type: DocumentTypeRef, + platform_version: &PlatformVersion, + ) -> Result { + // All good if schema is the same + if self.schema() == new_document_type.schema() { + return Ok(SimpleConsensusValidationResult::new()); + } + + let old_document_schema_json = match self.schema().try_to_validating_json() { + Ok(json_value) => json_value, + Err(e) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + DataContractError::ValueDecodingError(format!( + "invalid existing json schema structure for document type {}: {e}", + self.name() + )) + .into(), + )); + } + }; + + let new_document_schema_json = match new_document_type.schema().try_to_validating_json() { + Ok(json_value) => json_value, + Err(e) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + DataContractError::ValueDecodingError(format!( + "invalid new json schema structure for document type {}: {e}", + self.name() + )) + .into(), + )); + } + }; + + let compatibility_validation_result = validate_schema_compatibility( + &old_document_schema_json, + &new_document_schema_json, + platform_version, + )?; + + // Convert the compatibility errors to consensus errors + let errors = compatibility_validation_result + .errors + .into_iter() + .map(|operation| { + IncompatibleDocumentTypeSchemaError::new( + self.name().clone(), + operation.name, + operation.path, + ) + .into() + }) + .collect(); + + Ok(SimpleConsensusValidationResult::new_with_errors(errors)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::consensus::state::state_error::StateError; + use crate::consensus::ConsensusError; + use crate::data_contract::config::DataContractConfig; + use crate::data_contract::document_type::DocumentType; + use assert_matches::assert_matches; + use platform_value::platform_value; + use platform_value::Identifier; + + mod validate_config { + use super::*; + use std::collections::BTreeMap; + + #[test] + fn should_return_invalid_result_when_creation_restriction_mode_is_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "creationRestrictionMode": 1, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "creationRestrictionMode": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == "document type can not change creation restriction mode: changing from Owner Only to No Restrictions" + ); + } + + #[test] + fn should_return_invalid_result_when_trade_mode_is_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "tradeMode": 1, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "tradeMode": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == "document type can not change trade mode: changing from Direct Purchase to No Trading" + ); + } + + #[test] + fn should_return_invalid_result_when_documents_transferable_is_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "transferable": 1, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "transferable": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == "document type can not change whether its documents are transferable: changing from Always to Never" + ); + } + + #[test] + fn should_return_invalid_result_when_documents_can_be_deleted_is_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "canBeDeleted": true, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "canBeDeleted": false, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == "document type can not change whether its documents can be deleted: changing from true to false" + ); + } + + #[test] + fn should_return_invalid_result_when_documents_keep_history_is_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "documentsKeepHistory": true, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "documentsKeepHistory": false, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == "document type can not change whether it keeps history: changing from true to false" + ); + } + + #[test] + fn should_return_invalid_result_when_documents_mutable_is_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "documentsMutable": true, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "documentsMutable": false, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == "document type can not change whether its documents are mutable: changing from true to false" + ); + } + + #[test] + fn should_return_invalid_result_when_requires_identity_encryption_bounded_key_is_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "requiresIdentityEncryptionBoundedKey": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "requiresIdentityEncryptionBoundedKey": 1, + "additionalProperties": false, + }); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == "document type can not change whether it required an identity encryption bounded key: changing from Some(Unique) to Some(Multiple)" + ); + } + + #[test] + fn should_return_invalid_result_when_requires_identity_decryption_bounded_key_is_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "requiresIdentityDecryptionBoundedKey": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "requiresIdentityDecryptionBoundedKey": 2, + "additionalProperties": false, + }); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == "document type can not change whether it required an identity decryption bounded key: changing from Some(Unique) to Some(MultipleReferenceToLatest)" + ); + } + + #[test] + fn should_return_invalid_result_when_security_level_requirement_is_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "signatureSecurityLevelRequirement": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "signatureSecurityLevelRequirement": 1, + "additionalProperties": false, + }); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == "document type can not change the security level requirement for its updates: changing from MASTER to CRITICAL" + ); + } + + #[test] + fn should_return_invalid_result_when_documents_countable_is_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "documentsCountable": true, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "documentsCountable": false, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == "document type can not change whether its documents are countable: changing from true to false" + ); + } + + #[test] + fn should_return_invalid_result_when_range_countable_is_changed() { + // documents_countable must remain equal across old/new so that + // validate_config reaches the range_countable check below it. + // Setting documentsCountable: true on both keeps the + // documents_countable() getter true regardless of range_countable. + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "documentsCountable": true, + "rangeCountable": false, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "documentsCountable": true, + "rangeCountable": true, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == "document type can not change whether it is range countable: changing from false to true" + ); + } + + /// Builds old/new document types from two schemas and runs + /// `validate_config`, asserting the exact rejection message. The + /// per-flag tests below only differ in one schema keyword, so the + /// boilerplate lives here. + fn assert_config_change_rejected( + old_schema: platform_value::Value, + new_schema: platform_value::Value, + expected_message: &str, + ) { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + old_schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + new_schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == expected_message + ); + } + + fn schema_with_keep_flag(flag: &str, value: bool) -> platform_value::Value { + platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + flag: value, + "additionalProperties": false, + }) + } + + #[test] + fn should_return_invalid_result_when_documents_keep_transfer_history_is_changed() { + assert_config_change_rejected( + schema_with_keep_flag("keepsTransferHistory", true), + schema_with_keep_flag("keepsTransferHistory", false), + "document type can not change whether it keeps transfer history: changing from true to false", + ); + } + + #[test] + fn should_return_invalid_result_when_documents_keep_purchase_history_is_changed() { + assert_config_change_rejected( + schema_with_keep_flag("keepsPurchaseHistory", true), + schema_with_keep_flag("keepsPurchaseHistory", false), + "document type can not change whether it keeps purchase history: changing from true to false", + ); + } + + #[test] + fn should_return_invalid_result_when_documents_keep_pricing_history_is_changed() { + assert_config_change_rejected( + schema_with_keep_flag("keepsPricingHistory", true), + schema_with_keep_flag("keepsPricingHistory", false), + "document type can not change whether it keeps pricing history: changing from true to false", + ); + } + + /// `documentsSummable` must name an integer property listed in + /// `required`, so the summable schemas carry an `amount` field. + fn schema_with_summable( + documents_summable: bool, + range_summable: bool, + ) -> platform_value::Value { + let mut schema = platform_value!({ + "type": "object", + "properties": { + "amount": { + "type": "integer", + "position": 0, + } + }, + "required": ["amount"], + "additionalProperties": false, + }); + let map = schema.as_map_mut().expect("schema must be a map"); + if documents_summable { + map.push(("documentsSummable".into(), "amount".into())); + } + if range_summable { + map.push(("rangeSummable".into(), true.into())); + } + schema + } + + #[test] + fn should_return_invalid_result_when_documents_summable_is_changed() { + assert_config_change_rejected( + schema_with_summable(true, false), + schema_with_summable(false, false), + "document type can not change whether or how its documents are summable: changing from Some(\"amount\") to None", + ); + } + + #[test] + fn should_return_invalid_result_when_range_summable_is_changed() { + // `documentsSummable` stays equal across old and new so that + // validate_config reaches the range_summable check below it + // (mirrors the range_countable test above). + assert_config_change_rejected( + schema_with_summable(true, false), + schema_with_summable(true, true), + "document type can not change whether it is range summable: changing from false to true", + ); + } + } + + mod validate_schema { + use super::*; + use crate::consensus::basic::BasicError; + use std::collections::BTreeMap; + + #[test] + fn should_pass_when_schema_is_not_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "signatureSecurityLevelRequirement": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema.clone(), + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_schema(new_document_type.as_ref(), platform_version) + .expect("failed to validate schema compatibility"); + + assert!(result.is_valid()); + } + + #[test] + fn should_return_invalid_result_when_schemas_are_not_backward_compatible() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "signatureSecurityLevelRequirement": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema.clone(), + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "number", + "position": 0, + } + }, + "signatureSecurityLevelRequirement": 0, + "additionalProperties": false, + }); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_schema(new_document_type.as_ref(), platform_version) + .expect("failed to validate schema compatibility"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + )] if e.operation() == "replace" && e.property_path() == "/properties/test/type" + ); + } + } + + mod validate_byte_array_encoding { + use super::*; + use std::collections::BTreeMap; + + fn document_type_with_byte_array( + byte_array: platform_value::Value, + platform_version: &PlatformVersion, + ) -> DocumentType { + let schema = platform_value!({ + "type": "object", + "properties": { "blob": byte_array }, + "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") + } + + // Exercises the PUBLIC `validate_update` dispatcher (latest protocol + // version), so it also covers the dispatch into the current + // generation (v1 as of protocol v14). + fn validate_update_latest( + old_ba: platform_value::Value, + new_ba: platform_value::Value, + ) -> SimpleConsensusValidationResult { + let platform_version = PlatformVersion::latest(); + let old = document_type_with_byte_array(old_ba, platform_version); + let new = document_type_with_byte_array(new_ba, platform_version); + old.as_ref() + .validate_update(new.as_ref(), platform_version) + .expect("validate_update should not error") + } + + fn assert_rejected(old_ba: platform_value::Value, new_ba: platform_value::Value) { + let result = validate_update_latest(old_ba, new_ba); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError(StateError::DocumentTypeUpdateError(e))] + if e.additional_message().contains("byte array encoding") + ); + } + + fn assert_accepted(old_ba: platform_value::Value, new_ba: platform_value::Value) { + let result = validate_update_latest(old_ba, new_ba); + assert!( + result.is_valid(), + "expected the update to be accepted, got {:?}", + result.errors + ); + } + + #[test] + fn rejects_widening_fixed_byte_array_max_items() { + // The exact attack: a fixed (raw, no length prefix) 32-byte field + // widened to min 32 / max 64 flips it to the varint length-prefixed + // encoding, making every already-stored document undecodable. + assert_rejected( + platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), + platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":64,"position":0}), + ); + } + + #[test] + fn rejects_removing_max_items_from_fixed_byte_array() { + // Removing `maxItems` turns a fixed (raw, no length prefix) byte array + // into a variable (varint length-prefixed) one, so it must be rejected. + assert_rejected( + platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), + platform_value!({"type":"array","byteArray":true,"minItems":32,"position":0}), + ); + } + + #[test] + fn rejects_changing_fixed_byte_array_size() { + // The byte-array check runs before validate_schema, so a fixed-size + // change is caught here as an encoding change. + assert_rejected( + platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), + platform_value!({"type":"array","byteArray":true,"minItems":64,"maxItems":64,"position":0}), + ); + } + + #[test] + fn rejects_tightening_variable_to_fixed_byte_array() { + // The reverse flip: a variable (varint length-prefixed) byte array + // narrowed to fixed (raw) also changes the on-disk layout -- old docs + // carry a length prefix the new fixed type would misread. + assert_rejected( + platform_value!({"type":"array","byteArray":true,"minItems":1,"maxItems":32,"position":0}), + platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), + ); + } + + #[test] + fn accepts_unchanged_fixed_byte_array() { + assert_accepted( + platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), + platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), + ); + } + + #[test] + fn accepts_widening_already_variable_byte_array() { + // Variable-length on both sides: the on-disk encoding does not change, + // so widening the bound stays allowed. + assert_accepted( + platform_value!({"type":"array","byteArray":true,"minItems":1,"maxItems":32,"position":0}), + platform_value!({"type":"array","byteArray":true,"minItems":1,"maxItems":64,"position":0}), + ); + } + + #[test] + fn accepts_max_items_change_when_min_items_is_omitted() { + // With `minItems` omitted (None) the encoder always uses the variable + // (varint length-prefixed) path regardless of `maxItems` -- the raw + // path requires BOTH bounds present and equal. So changing `maxItems` + // does not change the on-disk encoding and must stay allowed. An + // implicit `minItems: 0` is NOT fixed-length (mirrors the encoder). + assert_accepted( + platform_value!({"type":"array","byteArray":true,"maxItems":0,"position":0}), + platform_value!({"type":"array","byteArray":true,"maxItems":1,"position":0}), + ); + } + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/mod.rs index 8accc4522f..a8fd6b443c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/mod.rs @@ -3,7 +3,9 @@ use crate::validation::SimpleConsensusValidationResult; use crate::ProtocolError; use platform_version::version::PlatformVersion; +mod common; mod v0; +mod v1; impl DocumentTypeRef<'_> { /// Verify that the update to the document type is valid. @@ -20,9 +22,10 @@ impl DocumentTypeRef<'_> { .validate_update { 0 => self.validate_update_v0(new_document_type, platform_version), + 1 => self.validate_update_v1(new_document_type, platform_version), version => Err(ProtocolError::UnknownVersionMismatch { method: "validate_update".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, }), } diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v0/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v0/mod.rs index 755cd1519a..46a4de0142 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v0/mod.rs @@ -1,12 +1,5 @@ -use crate::consensus::basic::data_contract::IncompatibleDocumentTypeSchemaError; -use crate::consensus::state::data_contract::document_type_update_error::DocumentTypeUpdateError; -use crate::data_contract::document_type::accessors::{ - DocumentTypeV0Getters, DocumentTypeV2Getters, -}; -use crate::data_contract::document_type::property::{ByteArrayPropertySizes, DocumentPropertyType}; -use crate::data_contract::document_type::schema::validate_schema_compatibility; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::data_contract::document_type::DocumentTypeRef; -use crate::data_contract::errors::DataContractError; use crate::validation::SimpleConsensusValidationResult; use crate::ProtocolError; use platform_version::version::PlatformVersion; @@ -44,1652 +37,124 @@ impl DocumentTypeRef<'_> { // Validate schema compatibility self.validate_schema(new_document_type, platform_version) } - - /// A byte array property whose `minItems == maxItems` is serialized as raw, - /// fixed-length bytes with no length prefix; any other size bounds make it - /// serialized with a variable-length (varint) length prefix. Crossing that - /// boundary -- or changing the fixed length itself -- silently changes the - /// on-disk layout of every already-stored document, so re-decoding old bytes - /// against the new type misreads them. JSON-schema compatibility treats - /// widening/removing `maxItems` as compatible, so this layout invariant must - /// be enforced separately. Runs before `validate_schema` so it cannot be - /// bypassed by a JSON-schema-compatible widening. - fn validate_byte_array_encoding_stability( - &self, - new_document_type: DocumentTypeRef, - ) -> SimpleConsensusValidationResult { - // Mirror the encoder/decoder exactly (see `encode_value_ref_with_size`): - // the raw, no-length-prefix path is used ONLY when BOTH bounds are present - // and equal. Any other shape -- including an omitted `minItems` (`None`) -- - // is varint length-prefixed, so an implicit `minItems: 0` must NOT be - // treated as fixed-length here or this guard would diverge from the actual - // on-disk layout. `Some(n)` => fixed raw encoding of length `n`; `None` => - // variable (varint length-prefixed) encoding. - fn fixed_length(sizes: &ByteArrayPropertySizes) -> Option { - match (sizes.min_size, sizes.max_size) { - (Some(min), Some(max)) if min == max => Some(min), - _ => None, - } - } - - let new_properties = new_document_type.flattened_properties(); - - for (path, old_property) in self.flattened_properties() { - let DocumentPropertyType::ByteArray(old_sizes) = &old_property.property_type else { - continue; - }; - - let Some(new_property) = new_properties.get(path) else { - continue; - }; - - let DocumentPropertyType::ByteArray(new_sizes) = &new_property.property_type else { - continue; - }; - - if fixed_length(old_sizes) != fixed_length(new_sizes) { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change the byte array encoding of property \ - '{}': changing its size bounds from (minItems: {:?}, maxItems: {:?}) \ - to (minItems: {:?}, maxItems: {:?}) alters the on-disk layout of \ - existing documents", - path, - old_sizes.min_size, - old_sizes.max_size, - new_sizes.min_size, - new_sizes.max_size, - ), - ) - .into(), - ); - } - } - - SimpleConsensusValidationResult::new() - } - - fn validate_config( - &self, - new_document_type: DocumentTypeRef, - ) -> SimpleConsensusValidationResult { - if new_document_type.creation_restriction_mode() != self.creation_restriction_mode() { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change creation restriction mode: changing from {} to {}", - self.creation_restriction_mode(), - new_document_type.creation_restriction_mode() - ), - ) - .into(), - ); - } - - if new_document_type.trade_mode() != self.trade_mode() { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change trade mode: changing from {} to {}", - self.trade_mode(), - new_document_type.trade_mode() - ), - ) - .into(), - ); - } - - if new_document_type.documents_transferable() != self.documents_transferable() { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether its documents are transferable: changing from {} to {}", - self.documents_transferable(), - new_document_type.documents_transferable() - ), - ) - .into(), - ); - } - - if new_document_type.documents_can_be_deleted() != self.documents_can_be_deleted() { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether its documents can be deleted: changing from {} to {}", - self.documents_can_be_deleted(), - new_document_type.documents_can_be_deleted() - ), - ) - .into(), - ); - } - - if new_document_type.documents_keep_history() != self.documents_keep_history() { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether it keeps history: changing from {} to {}", - self.documents_keep_history(), - new_document_type.documents_keep_history() - ), - ) - .into(), - ); - } - - if new_document_type.documents_keep_transfer_history() - != self.documents_keep_transfer_history() - { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether it keeps transfer history: changing from {} to {}", - self.documents_keep_transfer_history(), - new_document_type.documents_keep_transfer_history() - ), - ) - .into(), - ); - } - - if new_document_type.documents_keep_purchase_history() - != self.documents_keep_purchase_history() - { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether it keeps purchase history: changing from {} to {}", - self.documents_keep_purchase_history(), - new_document_type.documents_keep_purchase_history() - ), - ) - .into(), - ); - } - - if new_document_type.documents_keep_pricing_history() - != self.documents_keep_pricing_history() - { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether it keeps pricing history: changing from {} to {}", - self.documents_keep_pricing_history(), - new_document_type.documents_keep_pricing_history() - ), - ) - .into(), - ); - } - - if new_document_type.documents_mutable() != self.documents_mutable() { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether its documents are mutable: changing from {} to {}", - self.documents_mutable(), - new_document_type.documents_mutable() - ), - ) - .into(), - ); - } - - if new_document_type.requires_identity_encryption_bounded_key() - != self.requires_identity_encryption_bounded_key() - { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether it required an identity encryption bounded key: changing from {:?} to {:?}", - self.requires_identity_encryption_bounded_key(), - new_document_type.requires_identity_encryption_bounded_key() - ), - ) - .into(), - ); - } - - if new_document_type.requires_identity_decryption_bounded_key() - != self.requires_identity_decryption_bounded_key() - { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether it required an identity decryption bounded key: changing from {:?} to {:?}", - self.requires_identity_decryption_bounded_key(), - new_document_type.requires_identity_decryption_bounded_key() - ), - ) - .into(), - ); - } - - if new_document_type.security_level_requirement() != self.security_level_requirement() { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change the security level requirement for its updates: changing from {:?} to {:?}", - self.security_level_requirement(), - new_document_type.security_level_requirement() - ), - ) - .into(), - ); - } - - if new_document_type.documents_countable() != self.documents_countable() { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether its documents are countable: changing from {} to {}", - self.documents_countable(), - new_document_type.documents_countable() - ), - ) - .into(), - ); - } - - if new_document_type.range_countable() != self.range_countable() { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether it is range countable: changing from {} to {}", - self.range_countable(), - new_document_type.range_countable() - ), - ) - .into(), - ); - } - - // Sum-tree immutability — parallels the count flags above. - // Two checks: (1) whether the doctype is summable at all (the - // presence/absence of `documents_summable`), and (2) the *name* of - // the summed property. Changing either invalidates every on-disk - // sum contribution because grovedb's sum trees aggregate `i64` - // per merk node — a renamed property would silently double-count - // or under-count depending on which document field gets read. - if new_document_type.documents_summable() != self.documents_summable() { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether or how its documents are summable: changing from {:?} to {:?}", - self.documents_summable(), - new_document_type.documents_summable() - ), - ) - .into(), - ); - } - - if new_document_type.range_summable() != self.range_summable() { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether it is range summable: changing from {} to {}", - self.range_summable(), - new_document_type.range_summable() - ), - ) - .into(), - ); - } - - SimpleConsensusValidationResult::new() - } - - fn validate_schema( - &self, - new_document_type: DocumentTypeRef, - platform_version: &PlatformVersion, - ) -> Result { - // All good if schema is the same - if self.schema() == new_document_type.schema() { - return Ok(SimpleConsensusValidationResult::new()); - } - - let old_document_schema_json = match self.schema().try_to_validating_json() { - Ok(json_value) => json_value, - Err(e) => { - return Ok(SimpleConsensusValidationResult::new_with_error( - DataContractError::ValueDecodingError(format!( - "invalid existing json schema structure for document type {}: {e}", - self.name() - )) - .into(), - )); - } - }; - - let new_document_schema_json = match new_document_type.schema().try_to_validating_json() { - Ok(json_value) => json_value, - Err(e) => { - return Ok(SimpleConsensusValidationResult::new_with_error( - DataContractError::ValueDecodingError(format!( - "invalid new json schema structure for document type {}: {e}", - self.name() - )) - .into(), - )); - } - }; - - let compatibility_validation_result = validate_schema_compatibility( - &old_document_schema_json, - &new_document_schema_json, - platform_version, - )?; - - // Convert the compatibility errors to consensus errors - let errors = compatibility_validation_result - .errors - .into_iter() - .map(|operation| { - IncompatibleDocumentTypeSchemaError::new( - self.name().clone(), - operation.name, - operation.path, - ) - .into() - }) - .collect(); - - Ok(SimpleConsensusValidationResult::new_with_errors(errors)) - } } #[cfg(test)] mod tests { - use super::*; - - use crate::consensus::state::state_error::StateError; + use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::DocumentType; + use crate::data_contract::errors::{DataContractError, JsonSchemaError}; + use crate::ProtocolError; use assert_matches::assert_matches; - use platform_value::platform_value; - use platform_value::Identifier; - - mod validate_config { - use super::*; - use std::collections::BTreeMap; - - #[test] - fn should_return_invalid_result_when_creation_restriction_mode_is_changed() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "creationRestrictionMode": 1, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "creationRestrictionMode": 0, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_config(new_document_type.as_ref()); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError( - StateError::DocumentTypeUpdateError(e) - )] if e.additional_message() == "document type can not change creation restriction mode: changing from Owner Only to No Restrictions" - ); - } - - #[test] - fn should_return_invalid_result_when_trade_mode_is_changed() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "tradeMode": 1, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "tradeMode": 0, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_config(new_document_type.as_ref()); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError( - StateError::DocumentTypeUpdateError(e) - )] if e.additional_message() == "document type can not change trade mode: changing from Direct Purchase to No Trading" - ); - } - - #[test] - fn should_return_invalid_result_when_documents_transferable_is_changed() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "transferable": 1, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "transferable": 0, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_config(new_document_type.as_ref()); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError( - StateError::DocumentTypeUpdateError(e) - )] if e.additional_message() == "document type can not change whether its documents are transferable: changing from Always to Never" - ); - } - - #[test] - fn should_return_invalid_result_when_documents_can_be_deleted_is_changed() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "canBeDeleted": true, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "canBeDeleted": false, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_config(new_document_type.as_ref()); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError( - StateError::DocumentTypeUpdateError(e) - )] if e.additional_message() == "document type can not change whether its documents can be deleted: changing from true to false" - ); - } - - #[test] - fn should_return_invalid_result_when_documents_keep_history_is_changed() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "documentsKeepHistory": true, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "documentsKeepHistory": false, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_config(new_document_type.as_ref()); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError( - StateError::DocumentTypeUpdateError(e) - )] if e.additional_message() == "document type can not change whether it keeps history: changing from true to false" - ); - } - - #[test] - fn should_return_invalid_result_when_documents_mutable_is_changed() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "documentsMutable": true, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "documentsMutable": false, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_config(new_document_type.as_ref()); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError( - StateError::DocumentTypeUpdateError(e) - )] if e.additional_message() == "document type can not change whether its documents are mutable: changing from true to false" - ); - } - - #[test] - fn should_return_invalid_result_when_requires_identity_encryption_bounded_key_is_changed() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "requiresIdentityEncryptionBoundedKey": 0, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "requiresIdentityEncryptionBoundedKey": 1, - "additionalProperties": false, - }); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_config(new_document_type.as_ref()); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError( - StateError::DocumentTypeUpdateError(e) - )] if e.additional_message() == "document type can not change whether it required an identity encryption bounded key: changing from Some(Unique) to Some(Multiple)" - ); - } - - #[test] - fn should_return_invalid_result_when_requires_identity_decryption_bounded_key_is_changed() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "requiresIdentityDecryptionBoundedKey": 0, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "requiresIdentityDecryptionBoundedKey": 2, - "additionalProperties": false, - }); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_config(new_document_type.as_ref()); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError( - StateError::DocumentTypeUpdateError(e) - )] if e.additional_message() == "document type can not change whether it required an identity decryption bounded key: changing from Some(Unique) to Some(MultipleReferenceToLatest)" - ); - } - - #[test] - fn should_return_invalid_result_when_security_level_requirement_is_changed() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "signatureSecurityLevelRequirement": 0, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "signatureSecurityLevelRequirement": 1, - "additionalProperties": false, - }); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_config(new_document_type.as_ref()); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError( - StateError::DocumentTypeUpdateError(e) - )] if e.additional_message() == "document type can not change the security level requirement for its updates: changing from MASTER to CRITICAL" - ); - } - - #[test] - fn should_return_invalid_result_when_documents_countable_is_changed() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "documentsCountable": true, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "documentsCountable": false, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_config(new_document_type.as_ref()); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError( - StateError::DocumentTypeUpdateError(e) - )] if e.additional_message() == "document type can not change whether its documents are countable: changing from true to false" - ); - } - - #[test] - fn should_return_invalid_result_when_range_countable_is_changed() { - // documents_countable must remain equal across old/new so that - // validate_config reaches the range_countable check below it. - // Setting documentsCountable: true on both keeps the - // documents_countable() getter true regardless of range_countable. - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "documentsCountable": true, - "rangeCountable": false, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "documentsCountable": true, - "rangeCountable": true, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_config(new_document_type.as_ref()); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError( - StateError::DocumentTypeUpdateError(e) - )] if e.additional_message() == "document type can not change whether it is range countable: changing from false to true" - ); - } - } - - mod validate_schema { - use super::*; - use crate::consensus::basic::BasicError; - use std::collections::BTreeMap; - - #[test] - fn should_pass_when_schema_is_not_changed() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "signatureSecurityLevelRequirement": 0, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema.clone(), - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_schema(new_document_type.as_ref(), platform_version) - .expect("failed to validate schema compatibility"); - - assert!(result.is_valid()); - } - - #[test] - fn should_return_invalid_result_when_schemas_are_not_backward_compatible() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "signatureSecurityLevelRequirement": 0, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema.clone(), - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "number", - "position": 0, - } - }, - "signatureSecurityLevelRequirement": 0, - "additionalProperties": false, - }); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_schema(new_document_type.as_ref(), platform_version) - .expect("failed to validate schema compatibility"); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::BasicError( - BasicError::IncompatibleDocumentTypeSchemaError(e) - )] if e.operation() == "replace" && e.property_path() == "/properties/test/type" - ); - } - } - - mod validate_byte_array_encoding { - use super::*; - use std::collections::BTreeMap; - - fn document_type_with_byte_array( - byte_array: platform_value::Value, - platform_version: &PlatformVersion, - ) -> DocumentType { - let schema = platform_value!({ - "type": "object", - "properties": { "blob": byte_array }, - "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") - } - - // Exercises the PUBLIC `validate_update` dispatcher (latest protocol - // version), so it also covers the dispatch into v0. - fn validate_update_latest( - old_ba: platform_value::Value, - new_ba: platform_value::Value, - ) -> SimpleConsensusValidationResult { - let platform_version = PlatformVersion::latest(); - let old = document_type_with_byte_array(old_ba, platform_version); - let new = document_type_with_byte_array(new_ba, platform_version); - old.as_ref() - .validate_update(new.as_ref(), platform_version) - .expect("validate_update should not error") - } - - fn assert_rejected(old_ba: platform_value::Value, new_ba: platform_value::Value) { - let result = validate_update_latest(old_ba, new_ba); - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError(StateError::DocumentTypeUpdateError(e))] - if e.additional_message().contains("byte array encoding") - ); - } - - fn assert_accepted(old_ba: platform_value::Value, new_ba: platform_value::Value) { - let result = validate_update_latest(old_ba, new_ba); - assert!( - result.is_valid(), - "expected the update to be accepted, got {:?}", - result.errors - ); - } - - #[test] - fn rejects_widening_fixed_byte_array_max_items() { - // The exact attack: a fixed (raw, no length prefix) 32-byte field - // widened to min 32 / max 64 flips it to the varint length-prefixed - // encoding, making every already-stored document undecodable. - assert_rejected( - platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), - platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":64,"position":0}), - ); - } - - #[test] - fn rejects_removing_max_items_from_fixed_byte_array() { - // Removing `maxItems` turns a fixed (raw, no length prefix) byte array - // into a variable (varint length-prefixed) one, so it must be rejected. - assert_rejected( - platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), - platform_value!({"type":"array","byteArray":true,"minItems":32,"position":0}), - ); - } - - #[test] - fn rejects_changing_fixed_byte_array_size() { - // The byte-array check runs before validate_schema, so a fixed-size - // change is caught here as an encoding change. - assert_rejected( - platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), - platform_value!({"type":"array","byteArray":true,"minItems":64,"maxItems":64,"position":0}), - ); - } - - #[test] - fn rejects_tightening_variable_to_fixed_byte_array() { - // The reverse flip: a variable (varint length-prefixed) byte array - // narrowed to fixed (raw) also changes the on-disk layout -- old docs - // carry a length prefix the new fixed type would misread. - assert_rejected( - platform_value!({"type":"array","byteArray":true,"minItems":1,"maxItems":32,"position":0}), - platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), - ); - } - - #[test] - fn accepts_unchanged_fixed_byte_array() { - assert_accepted( - platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), - platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), - ); - } - - #[test] - fn accepts_widening_already_variable_byte_array() { - // Variable-length on both sides: the on-disk encoding does not change, - // so widening the bound stays allowed. - assert_accepted( - platform_value!({"type":"array","byteArray":true,"minItems":1,"maxItems":32,"position":0}), - platform_value!({"type":"array","byteArray":true,"minItems":1,"maxItems":64,"position":0}), - ); - } - - #[test] - fn accepts_max_items_change_when_min_items_is_omitted() { - // With `minItems` omitted (None) the encoder always uses the variable - // (varint length-prefixed) path regardless of `maxItems` -- the raw - // path requires BOTH bounds present and equal. So changing `maxItems` - // does not change the on-disk encoding and must stay allowed. An - // implicit `minItems: 0` is NOT fixed-length (mirrors the encoder). - assert_accepted( - platform_value!({"type":"array","byteArray":true,"maxItems":0,"position":0}), - platform_value!({"type":"array","byteArray":true,"maxItems":1,"position":0}), - ); - } + use platform_value::{platform_value, Identifier, Value}; + use platform_version::version::PlatformVersion; + use std::collections::BTreeMap; + + fn doc_type_with_indices(indices: Value, platform_version: &PlatformVersion) -> DocumentType { + let schema = platform_value!({ + "type": "object", + "properties": { + "a": {"type": "string", "position": 0, "maxLength": 60_u32}, + "b": {"type": "string", "position": 1, "maxLength": 60_u32}, + "c": {"type": "string", "position": 2, "maxLength": 60_u32}, + }, + "indices": indices, + "additionalProperties": false, + }); + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "test", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") } - /// The ranking axes are index-level, so `validate_config` — which covers - /// the *doctype*-level count / sum flags just above — is deliberately not - /// where they are enforced. They ride the index-structure comparison - /// (`IndexLevel::validate_update`) that `validate_update_v0` runs right - /// after the config check. These tests exercise the PUBLIC dispatcher so - /// that routing is pinned, not just the helper in isolation. - mod validate_update_ranked_indices { - use super::*; - use std::collections::BTreeMap; - - /// `review` doctype, one averageable index over `restaurantId`, with - /// `rankedAverageable` set to the supplied value. - fn document_type_with_ranked_index( - ranked_averageable: bool, - platform_version: &PlatformVersion, - ) -> DocumentType { - let schema = platform_value!({ - "type": "object", - "properties": { - // 32 rather than the generic 63-character index limit: - // an index declaring a ranking axis bounds its group key - // more tightly (59 characters on the Avg axis), and both - // halves of these tests have to build the same doctype - // shape with only `rankedAverageable` differing. - "restaurantId": { - "type": "string", - "maxLength": 32, - "position": 0, - }, - "grade": { - "type": "integer", - "minimum": 0, - "maximum": 100, - "position": 1, - }, - }, - "required": ["restaurantId", "grade"], - "additionalProperties": false, - "indices": [{ - "name": "byRestaurant", - "properties": [{ "restaurantId": "asc" }], - "averageable": "grade", - "rangeAverageable": true, - "rankedAverageable": ranked_averageable, - }], - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - DocumentType::try_from_schema( - Identifier::random(), - 1, - config.version(), - "review", - schema, - None, - &BTreeMap::new(), - &config, - true, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create document type") - } - - #[test] - fn should_return_invalid_result_when_ranked_averageable_is_changed() { - let platform_version = PlatformVersion::latest(); - let old = document_type_with_ranked_index(false, platform_version); - let new = document_type_with_ranked_index(true, platform_version); - - let result = old - .as_ref() - .validate_update(new.as_ref(), platform_version) - .expect("validate_update should not error"); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::BasicError( - crate::consensus::basic::BasicError::DataContractInvalidIndexDefinitionUpdateError(e) - )] if e.index_path() == "restaurantId -> (ranked_averageable: false -> true)" - ); - } - - #[test] - fn should_pass_when_ranked_averageable_is_unchanged() { - let platform_version = PlatformVersion::latest(); - let old = document_type_with_ranked_index(true, platform_version); - let new = document_type_with_ranked_index(true, platform_version); - - let result = old - .as_ref() - .validate_update(new.as_ref(), platform_version) - .expect("validate_update should not error"); - - assert!( - result.is_valid(), - "an unchanged ranked index must not be rejected, got {:?}", - result.errors - ); - } + // Pins the frozen v0 behavior that motivated v1 (protocol v14): the + // `IndexLevel` tree numbers its levels with a counter that follows the + // iteration order of `indices` — a BTreeMap keyed by index NAME — so the + // outcome of a semantically identical index addition depends on where the + // new index's name sorts relative to existing ones. + // + // Old contract: "j" on [c], "k" on [a, b]. Adding an index on [a] (a + // prefix of "k", terminating at an existing tree level): + // - named "i" (sorts first): every level is renumbered, so the + // identifier-equality subset check rejects with an opaque + // "a -> Invalid path". + // - named "z" (sorts last): the numbering is unchanged, the tree + // comparison passes, and the update falls through to the JSON-schema + // compatibility check, which hard-errors because no compatibility rule + // exists for the `indices` keyword. + // + // v0 stays byte-for-byte at this behavior for replay of protocol + // versions <= 13; v1 replaces the tree comparison with a name-keyed + // index-definition comparison. + #[test] + fn v0_index_addition_outcome_depends_on_index_name_sort_order() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13 must exist"); + + let old = doc_type_with_indices( + platform_value!([ + {"name": "j", "properties": [{"c": "asc"}]}, + {"name": "k", "properties": [{"a": "asc"}, {"b": "asc"}]}, + ]), + platform_version, + ); + + let new_early_name = doc_type_with_indices( + platform_value!([ + {"name": "i", "properties": [{"a": "asc"}]}, + {"name": "j", "properties": [{"c": "asc"}]}, + {"name": "k", "properties": [{"a": "asc"}, {"b": "asc"}]}, + ]), + platform_version, + ); + + let new_late_name = doc_type_with_indices( + platform_value!([ + {"name": "j", "properties": [{"c": "asc"}]}, + {"name": "k", "properties": [{"a": "asc"}, {"b": "asc"}]}, + {"name": "z", "properties": [{"a": "asc"}]}, + ]), + platform_version, + ); + + let early_result = old + .as_ref() + .validate_update(new_early_name.as_ref(), platform_version) + .expect("early-name addition should produce a validation result"); + + assert_matches!( + early_result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DataContractInvalidIndexDefinitionUpdateError(e) + )] if e.index_path() == "a -> Invalid path" + ); + + // The identical addition under a late-sorting name passes the tree + // comparison and instead hard-errors in the schema compatibility + // check ("schema keyword 'indices' ... is not supported"). + let late_error = old + .as_ref() + .validate_update(new_late_name.as_ref(), platform_version) + .expect_err("late-name addition should error in schema compatibility"); + + assert_matches!( + late_error, + ProtocolError::DataContractError(DataContractError::JsonSchema( + JsonSchemaError::SchemaCompatibilityValidationError(message) + )) if message == "schema keyword 'indices' at path '/indices/2' is not supported" + ); } } diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs new file mode 100644 index 0000000000..bd950436ea --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs @@ -0,0 +1,464 @@ +//! Protocol v14 generation of document type update validation. +//! +//! v0 validated index changes by comparing `IndexLevel` trees whose +//! `level_identifier`s are assigned by an incrementing counter while walking +//! `indices` — a BTreeMap keyed by index NAME. Adding an index whose name +//! sorted before an existing one renumbered every level, so the identifier +//! equality check rejected the update with an opaque "Invalid path", while +//! the semantically identical addition under a late-sorting name passed the +//! tree comparison (and then hard-errored in the JSON-schema compatibility +//! check, which has no rule for the `indices` keyword). Which consensus +//! outcome a contract owner got therefore depended purely on how the new +//! index's name sorted. +//! +//! v1 drops the tree comparison and compares the parsed index definitions +//! by name instead: any added, removed or modified index is rejected with a +//! deterministic `DataContractInvalidIndexDefinitionUpdateError` naming the +//! offending index, independent of name sort order. This does not change +//! which updates are ultimately acceptable — under v0 no index modification +//! could ever pass the full pipeline (whatever survived the tree comparison +//! was always rejected by the `indices` schema-compatibility hard error) — +//! it makes the rejection deterministic, clean, and correctly labeled. + +use crate::consensus::basic::data_contract::DataContractInvalidIndexDefinitionUpdateError; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::DocumentTypeRef; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +impl DocumentTypeRef<'_> { + #[inline(always)] + pub(super) fn validate_update_v1( + &self, + new_document_type: DocumentTypeRef, + platform_version: &PlatformVersion, + ) -> Result { + // Validate configuration + let result = self.validate_config(new_document_type); + + if !result.is_valid() { + return Ok(result); + } + + // Validate that index definitions are unchanged + let result = self.validate_index_definitions_unchanged(new_document_type); + + if !result.is_valid() { + return Ok(result); + } + + // Validate that no byte array property changes its on-disk encoding + let result = self.validate_byte_array_encoding_stability(new_document_type); + + if !result.is_valid() { + return Ok(result); + } + + // Validate schema compatibility + self.validate_schema(new_document_type, platform_version) + } + + /// Index definitions are immutable once a document type is registered: + /// Drive lays out the index trees at contract creation and never + /// backfills them, so an added index would silently miss every + /// pre-update document and a removed or modified one would orphan + /// on-disk subtrees. Compare the definitions by index name — the + /// comparison must not depend on where a changed index's name sorts + /// relative to the document type's other indexes. + fn validate_index_definitions_unchanged( + &self, + new_document_type: DocumentTypeRef, + ) -> SimpleConsensusValidationResult { + let old_indexes = self.indexes(); + let new_indexes = new_document_type.indexes(); + + for (name, old_index) in old_indexes { + match new_indexes.get(name) { + None => { + return SimpleConsensusValidationResult::new_with_error( + DataContractInvalidIndexDefinitionUpdateError::new( + self.name().to_string(), + format!("removed index '{name}'"), + ) + .into(), + ); + } + Some(new_index) if new_index != old_index => { + return SimpleConsensusValidationResult::new_with_error( + DataContractInvalidIndexDefinitionUpdateError::new( + self.name().to_string(), + format!("changed index '{name}'"), + ) + .into(), + ); + } + _ => {} + } + } + + for name in new_indexes.keys() { + if !old_indexes.contains_key(name) { + return SimpleConsensusValidationResult::new_with_error( + DataContractInvalidIndexDefinitionUpdateError::new( + self.name().to_string(), + format!("added index '{name}'"), + ) + .into(), + ); + } + } + + SimpleConsensusValidationResult::new() + } +} + +#[cfg(test)] +mod tests { + use crate::consensus::basic::BasicError; + use crate::consensus::ConsensusError; + use crate::data_contract::config::DataContractConfig; + use crate::data_contract::document_type::DocumentType; + use assert_matches::assert_matches; + use platform_value::{platform_value, Identifier, Value}; + use platform_version::version::PlatformVersion; + use std::collections::BTreeMap; + + fn doc_type_with_indices(indices: Value, platform_version: &PlatformVersion) -> DocumentType { + let schema = platform_value!({ + "type": "object", + "properties": { + "a": {"type": "string", "position": 0, "maxLength": 60_u32}, + "b": {"type": "string", "position": 1, "maxLength": 60_u32}, + "c": {"type": "string", "position": 2, "maxLength": 60_u32}, + }, + "indices": indices, + "additionalProperties": false, + }); + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "test", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") + } + + fn old_doc_type(platform_version: &PlatformVersion) -> DocumentType { + doc_type_with_indices( + platform_value!([ + {"name": "j", "properties": [{"c": "asc"}]}, + {"name": "k", "properties": [{"a": "asc"}, {"b": "asc"}]}, + ]), + platform_version, + ) + } + + // The v0 regression this generation fixes: the outcome of adding an + // index must not depend on where its name sorts relative to the + // document type's existing indexes. Under v0, adding "i" on [a] was + // rejected with an opaque "Invalid path" (level renumbering) while the + // semantically identical "z" on [a] passed the tree comparison and + // hard-errored later in schema compatibility. Under v1 both get the + // same clean rejection naming the added index. + #[test] + fn should_reject_added_index_identically_regardless_of_name_sort_order() { + let platform_version = PlatformVersion::latest(); + + let old = old_doc_type(platform_version); + + let new_early_name = doc_type_with_indices( + platform_value!([ + {"name": "i", "properties": [{"a": "asc"}]}, + {"name": "j", "properties": [{"c": "asc"}]}, + {"name": "k", "properties": [{"a": "asc"}, {"b": "asc"}]}, + ]), + platform_version, + ); + + let new_late_name = doc_type_with_indices( + platform_value!([ + {"name": "j", "properties": [{"c": "asc"}]}, + {"name": "k", "properties": [{"a": "asc"}, {"b": "asc"}]}, + {"name": "z", "properties": [{"a": "asc"}]}, + ]), + platform_version, + ); + + let early_result = old + .as_ref() + .validate_update(new_early_name.as_ref(), platform_version) + .expect("validate_update should not error"); + + assert_matches!( + early_result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DataContractInvalidIndexDefinitionUpdateError(e) + )] if e.index_path() == "added index 'i'" + ); + + let late_result = old + .as_ref() + .validate_update(new_late_name.as_ref(), platform_version) + .expect("validate_update should not error"); + + assert_matches!( + late_result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DataContractInvalidIndexDefinitionUpdateError(e) + )] if e.index_path() == "added index 'z'" + ); + } + + // Renaming an index leaves the `IndexLevel` tree unchanged (index names + // are not part of it), so under v0 a rename either slipped through to a + // schema-compatibility hard error or — when it shifted the name-order + // level numbering — was rejected as "Invalid path". Under v1 it is a + // clean, deterministic rejection. + #[test] + fn should_reject_renamed_index_with_clean_error() { + let platform_version = PlatformVersion::latest(); + + let old = old_doc_type(platform_version); + + // "j" renamed to "zz" — this also shifts the v0 level numbering + // because "zz" sorts after "k" while "j" sorted before it. + let new = doc_type_with_indices( + platform_value!([ + {"name": "k", "properties": [{"a": "asc"}, {"b": "asc"}]}, + {"name": "zz", "properties": [{"c": "asc"}]}, + ]), + platform_version, + ); + + let result = old + .as_ref() + .validate_update(new.as_ref(), platform_version) + .expect("validate_update should not error"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DataContractInvalidIndexDefinitionUpdateError(e) + )] if e.index_path() == "removed index 'j'" + ); + } + + #[test] + fn should_reject_removed_index() { + let platform_version = PlatformVersion::latest(); + + let old = old_doc_type(platform_version); + + let new = doc_type_with_indices( + platform_value!([ + {"name": "k", "properties": [{"a": "asc"}, {"b": "asc"}]}, + ]), + platform_version, + ); + + let result = old + .as_ref() + .validate_update(new.as_ref(), platform_version) + .expect("validate_update should not error"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DataContractInvalidIndexDefinitionUpdateError(e) + )] if e.index_path() == "removed index 'j'" + ); + } + + #[test] + fn should_reject_index_with_added_property() { + let platform_version = PlatformVersion::latest(); + + let old = old_doc_type(platform_version); + + let new = doc_type_with_indices( + platform_value!([ + {"name": "j", "properties": [{"c": "asc"}, {"a": "asc"}]}, + {"name": "k", "properties": [{"a": "asc"}, {"b": "asc"}]}, + ]), + platform_version, + ); + + let result = old + .as_ref() + .validate_update(new.as_ref(), platform_version) + .expect("validate_update should not error"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DataContractInvalidIndexDefinitionUpdateError(e) + )] if e.index_path() == "changed index 'j'" + ); + } + + // Flipping `unique` leaves the v0 `IndexLevel` subset comparison + // blind (it never compared terminator info), so v0 let it through to + // the schema-compatibility hard error. v1 rejects it cleanly. + #[test] + fn should_reject_index_with_changed_unique_flag() { + let platform_version = PlatformVersion::latest(); + + let old = old_doc_type(platform_version); + + let new = doc_type_with_indices( + platform_value!([ + {"name": "j", "properties": [{"c": "asc"}], "unique": true}, + {"name": "k", "properties": [{"a": "asc"}, {"b": "asc"}]}, + ]), + platform_version, + ); + + let result = old + .as_ref() + .validate_update(new.as_ref(), platform_version) + .expect("validate_update should not error"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DataContractInvalidIndexDefinitionUpdateError(e) + )] if e.index_path() == "changed index 'j'" + ); + } + + #[test] + fn should_pass_when_indices_are_unchanged() { + let platform_version = PlatformVersion::latest(); + + let old = old_doc_type(platform_version); + let new = old_doc_type(platform_version); + + let result = old + .as_ref() + .validate_update(new.as_ref(), platform_version) + .expect("validate_update should not error"); + + assert!( + result.is_valid(), + "unchanged document type should be accepted, got {:?}", + result.errors + ); + } + + // Ranked aggregate indexes (protocol v14 grammar) are covered by the + // same name-keyed definition comparison as every other index flag: + // toggling a ranking axis after creation changes the on-disk tree + // variant, so it must be rejected. The ranking axes are index-level, + // so `validate_config` — which covers the *doctype*-level count / sum + // flags — is deliberately not where they are enforced. These tests + // exercise the PUBLIC dispatcher so that routing is pinned, not just + // the helper in isolation; they live here rather than in v0 because + // the ranked grammar only exists at protocol v14, where + // validate_update dispatches to v1. + mod validate_update_ranked_indices { + use super::*; + + /// `review` doctype, one averageable index over `restaurantId`, with + /// `rankedAverageable` set to the supplied value. + fn document_type_with_ranked_index( + ranked_averageable: bool, + platform_version: &PlatformVersion, + ) -> DocumentType { + let schema = platform_value!({ + "type": "object", + "properties": { + // 32 rather than the generic 63-character index limit: + // an index declaring a ranking axis bounds its group key + // more tightly (59 characters on the Avg axis), and both + // halves of these tests have to build the same doctype + // shape with only `rankedAverageable` differing. + "restaurantId": { + "type": "string", + "maxLength": 32, + "position": 0, + }, + "grade": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "position": 1, + }, + }, + "required": ["restaurantId", "grade"], + "additionalProperties": false, + "indices": [{ + "name": "byRestaurant", + "properties": [{ "restaurantId": "asc" }], + "averageable": "grade", + "rangeAverageable": true, + "rankedAverageable": ranked_averageable, + }], + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + DocumentType::try_from_schema( + Identifier::random(), + 1, + config.version(), + "review", + schema, + None, + &BTreeMap::new(), + &config, + true, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") + } + + #[test] + fn should_return_invalid_result_when_ranked_averageable_is_changed() { + let platform_version = PlatformVersion::latest(); + let old = document_type_with_ranked_index(false, platform_version); + let new = document_type_with_ranked_index(true, platform_version); + + let result = old + .as_ref() + .validate_update(new.as_ref(), platform_version) + .expect("validate_update should not error"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DataContractInvalidIndexDefinitionUpdateError(e) + )] if e.index_path() == "changed index 'byRestaurant'" + ); + } + + #[test] + fn should_pass_when_ranked_averageable_is_unchanged() { + let platform_version = PlatformVersion::latest(); + let old = document_type_with_ranked_index(true, platform_version); + let new = document_type_with_ranked_index(true, platform_version); + + let result = old + .as_ref() + .validate_update(new.as_ref(), platform_version) + .expect("validate_update should not error"); + + assert!( + result.is_valid(), + "an unchanged ranked index must not be rejected, got {:?}", + result.errors + ); + } + } +} diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs index 222dc4104c..1149cdaa32 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs @@ -4,6 +4,7 @@ pub mod v1; pub mod v2; pub mod v3; pub mod v4; +pub mod v5; #[derive(Clone, Debug, Default)] pub struct DPPValidationVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v5.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v5.rs new file mode 100644 index 0000000000..cce9f719d5 --- /dev/null +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v5.rs @@ -0,0 +1,22 @@ +use crate::version::dpp_versions::dpp_validation_versions::{ + DPPValidationVersions, DocumentTypeValidationVersions, +}; + +use super::v4::DPP_VALIDATION_VERSIONS_V4; + +/// Protocol v14 validation versions. +/// +/// v1 document-type update validation compares index definitions by name +/// instead of comparing `IndexLevel` trees whose level identifiers are +/// assigned by an iteration-order counter. In v0 the counter follows the +/// index-name sort order, so whether a rejected index change surfaced as a +/// proper consensus error or as an opaque "Invalid path" / internal error +/// depended on where the changed index's name sorted relative to the +/// document type's other indexes. +pub const DPP_VALIDATION_VERSIONS_V5: DPPValidationVersions = DPPValidationVersions { + document_type: DocumentTypeValidationVersions { + validate_update: 1, + ..DPP_VALIDATION_VERSIONS_V4.document_type + }, + ..DPP_VALIDATION_VERSIONS_V4 +}; diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 69b0e04f66..451e1daa06 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -11,7 +11,7 @@ use crate::version::dpp_versions::dpp_state_transition_method_versions::v1::STAT use crate::version::dpp_versions::dpp_state_transition_serialization_versions::v2::STATE_TRANSITION_SERIALIZATION_VERSIONS_V2; use crate::version::dpp_versions::dpp_state_transition_versions::v3::STATE_TRANSITION_VERSIONS_V3; use crate::version::dpp_versions::dpp_token_versions::v2::TOKEN_VERSIONS_V2; -use crate::version::dpp_versions::dpp_validation_versions::v4::DPP_VALIDATION_VERSIONS_V4; +use crate::version::dpp_versions::dpp_validation_versions::v5::DPP_VALIDATION_VERSIONS_V5; use crate::version::dpp_versions::dpp_voting_versions::v2::VOTING_VERSION_V2; use crate::version::dpp_versions::DPPVersion; use crate::version::drive_abci_versions::drive_abci_checkpoint_parameters::v1::DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1; @@ -104,7 +104,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { }, dpp: DPPVersion { costs: DPP_COSTS_VERSIONS_V1, - validation: DPP_VALIDATION_VERSIONS_V4, + validation: DPP_VALIDATION_VERSIONS_V5, state_transition_serialization_versions: STATE_TRANSITION_SERIALIZATION_VERSIONS_V2, state_transition_conversion_versions: STATE_TRANSITION_CONVERSION_VERSIONS_V2, state_transition_method_versions: STATE_TRANSITION_METHOD_VERSIONS_V1,