diff --git a/book/src/contract-keywords/property-constraints.md b/book/src/contract-keywords/property-constraints.md index ff68febfe7c..65e102bf28a 100644 --- a/book/src/contract-keywords/property-constraints.md +++ b/book/src/contract-keywords/property-constraints.md @@ -96,6 +96,10 @@ An integer expression is one of: | `divide` | `{ "divide": [a, b] }` | The Euclidean quotient of `a` by `b` | | `modulo` | `{ "modulo": [a, b] }` | The Euclidean remainder of `a` by `b`, never negative | | `power` | `{ "power": [a, b] }` | `a` to the power `b` | +| `length`, `byteLength` | `{ "length": "title" }` | The characters (as `maxLength` counts them) or UTF-8 bytes (as `maxBytes` counts them) of a string property, 0 when the document leaves it out | +| `count` | `{ "count": "tags" }` | The items of an array property, or the bytes of a byte array property, 0 when the document leaves it out | + +Where `maxLength`, `maxBytes` and `maxItems` bound one property by a fixed number, a size can be compared with another property or bounded only under a condition: `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit. A size never breaks a rule by itself: a property left out or null has size 0, and so would a value of another type, which the schema validation refuses first. Two more forms appear only in string and identifier comparisons, never inside arithmetic: @@ -162,7 +166,7 @@ The meta-schema checks the shape (`JsonSchemaError`, 10101): The parser then checks the rules against the document type (`InvalidContractStructure`, 10231): -- every path an integer expression reads names an integer or boolean property; every path compared with a string names a string property; every path compared with an identifier names an identifier property; every path `present` or `absent` tests names a property of any type, an object included; +- every path an integer expression reads names an integer or boolean property; every path `length` or `byteLength` measures names a string property, and every path `count` counts an array or byte array property; every path compared with a string names a string property; every path compared with an identifier names an identifier property; every path `present` or `absent` tests names a property of any type, an object included; - no rule reads a property that is `transient` or inside a transient object, since a stored document could never be held to it; - every comparison and `in` reads at least one property: a comparison of constants would hold for every document or for none; - strings and identifiers are compared only with `equal`, `notEqual` and `in`; a string is never compared with an identifier; a property is never compared with itself; @@ -188,7 +192,7 @@ A rule within 32 nodes is never deep enough to reach the 64-level bound. Nodes a | `present`, `absent` | 1 | | `anyOf`, `allOf` | 1, plus their conditions | | `not` | 1, plus its condition | -| An integer, a path or an `ifAbsent` | 1 | +| An integer, a path, an `ifAbsent` or a size (`length`, `byteLength`, `count`) | 1 | | `add`, `multiply`, `subtract`, `divide`, `modulo`, `power` | 1, plus their operands | `depositCoversOrder` above is 7 nodes (the comparison, `multiply`, `add` and four paths), and `closedNeedsClosedAt` is 5. An `in` fits up to 30 values in 32 nodes. diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 013db9df930..b70e0eabac4 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -724,7 +724,8 @@ Conditions nest: `{ "not": { "allOf": [{ "equal": ["price", 0] }, { "greaterThan - a string, the dotted path of an integer or boolean property of the document type (`"price"`, `"meta.total"`, `"waiveFee"`), whose value it takes, 0 when the document leaves the property out. A boolean reads as 1 for true and 0 for false, so `{ "equal": [{ "multiply": ["waiveFee", "fee"] }, 0] }` says a waived fee is 0; - `{ "ifAbsent": [path, value] }`, the property's value, or `value` when the document leaves it out (an integer value here; a string value gives a string property a default in a string comparison instead); - `{ "add": [...] }` or `{ "multiply": [...] }` over two or more operands; -- `{ "subtract": [a, b] }`, `{ "divide": [a, b] }`, `{ "modulo": [a, b] }` or `{ "power": [a, b] }`. +- `{ "subtract": [a, b] }`, `{ "divide": [a, b] }`, `{ "modulo": [a, b] }` or `{ "power": [a, b] }`; +- a size: `{ "length": path }`, the characters of a string property (counted as `maxLength` counts them), `{ "byteLength": path }`, its UTF-8 bytes (as `maxBytes` counts them), or `{ "count": path }`, the items of an array property or the bytes of a byte array property. Where `maxLength`, `maxBytes` and `maxItems` bound one property by a fixed number, a size can be compared with another property or bounded only under a condition: `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit, and `{ "anyOf": [{ "greaterThan": ["fee", 0] }, { "lessThanOrEqual": [{ "length": "title" }, 20] }] }` keeps a free listing's title short. A property the document leaves out or sets to null has size 0, and a size never breaks a rule by itself (a value of another type would read as 0 too, but the schema validation refuses it first). A JSON number is always a value and a string always a path, so a property named `100` is not confused with the number, and the rule is a tree the meta-schema can check rather than a string with precedence rules to parse. Consensus holds nothing but this tree; an SDK may offer an infix spelling that compiles to it. @@ -732,11 +733,11 @@ The arithmetic is exact over `i128`. Operands are evaluated left to right, and e Conditions are checked in declared order and no further than the outcome needs: a comparison evaluates its left side, then its right; `anyOf` stops at the first condition that holds and `allOf` at the first that fails. A fault in a condition that is checked breaks the rule whatever the others would say, and `not` does not turn it into a pass. So an earlier condition guards a later one: `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` holds for a `b` of 0 without dividing by it, while the same two conditions the other way round divide by zero and break the rule. -The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path an operand reads names an integer or boolean property of the type, every path compared with a string names a string property (and every constant compared with one that declares an `enum` is one of its values) (a nested one by its dotted path) and every path `present` or `absent` tests names a property of the type, and that neither is `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison and `in` reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `in` lists a value twice, that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every `in` and each value it lists, every `const`, every `present` or `absent`, every arithmetic operator and every operand, and that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value). The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. +The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path an operand reads names an integer or boolean property of the type, every path `length` or `byteLength` measures a string property, every path `count` counts an array or byte array property, every path compared with a string names a string property (and every constant compared with one that declares an `enum` is one of its values) (a nested one by its dotted path) and every path `present` or `absent` tests names a property of the type, and that neither is `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison and `in` reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `in` lists a value twice, that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every `in` and each value it lists, every `const`, every `present` or `absent`, every arithmetic operator and every operand (a size is one), and that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value). The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. Enforcement lives in `DataContract::validate_document_properties` (generation 0, extended in place: the call is inert before protocol version 14, where `validate_property_constraints` is `None`), after the schema validation. Document create and replace structure validation call it, so consensus applies the rules, and so does every client that validates a document before sending it. The rules are checked in name order against the document's properties, which for a replace is the whole document, and the first one broken fails with `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the document type, the rule and why: the rule does not hold, or evaluating it overflowed, divided by zero, raised to a negative power or read a value that is not an integer. The check reads no state and changes nothing stored, so it adds no fee; the limits bound its cost. `$ownerId` reads the writer, passed in by create and replace (`validate_document_properties` takes the owner; a client that does not know it passes `None`, and `$ownerId` then equals no identifier). Transfers and purchases change no property but the owner, so only the rules reading `$ownerId` are judged again, with the new owner (`DocumentTypeV0Methods::validate_property_constraints_for_new_owner`, next to the `distinctFrom` check); price updates change neither and are not judged. -In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`: a comparison, an `in`, a string comparison (`TextCompare`, `TextIn`), a `present` or `absent`, or an `anyOf`, `allOf` or `not` of them, empty on types that predate the keyword; `property_reads` lists what a rule reads and whether by value or by presence), each rule's `holds` and `violation` evaluate it against a document's data, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. +In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`: a comparison, an `in`, a string comparison (`TextCompare`, `TextIn`), a `present` or `absent`, or an `anyOf`, `allOf` or `not` of them, empty on types that predate the keyword; `property_reads` lists what a rule reads and how: by value, by presence, by size (`Length`, `Count`) or in a comparison of strings or identifiers), each rule's `holds` and `violation` evaluate it against a document's data, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. ## Rules and Guidelines diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 21db109a290..82b6e94d79d 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -425,7 +425,7 @@ From protocol version 14 a document type can declare rules its documents' proper } ``` -The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, with another string property (`{ "notEqual": ["fromCurrency", "toCurrency"] }`), or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none, unless `{ "ifAbsent": ["status", "open"] }` gives it a default. Identifier properties compare the same way, with base58 constants: `{ "equal": ["paymentToken", { "const": "" }] }`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. `$ownerId`, the document's owner, is an identifier operand as well (`{ "equal": ["authorId", "$ownerId"] }`), and a transfer or purchase that would break such a rule is refused. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0). A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. +The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, with another string property (`{ "notEqual": ["fromCurrency", "toCurrency"] }`), or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none, unless `{ "ifAbsent": ["status", "open"] }` gives it a default. Identifier properties compare the same way, with base58 constants: `{ "equal": ["paymentToken", { "const": "" }] }`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. `$ownerId`, the document's owner, is an identifier operand as well (`{ "equal": ["authorId", "$ownerId"] }`), and a transfer or purchase that would break such a rule is refused. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0), or a size: `{ "length": path }` and `{ "byteLength": path }` give the characters and UTF-8 bytes of a string property, and `{ "count": path }` the items of an array or the bytes of a byte array, so `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit (a size read is reported with kind `length` or `count`). A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. Consensus checks every rule on each create and replace, and rejects a document that breaks one, or whose rule overflows, divides by zero or raises to a negative power. The code reaches JS as `error.code`, and the message names the rule: diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index dd5bf9b5063..1d4d3e65ae7 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -136,7 +136,7 @@ "uniqueItems": true }, "propertyConstraintExpression": { - "description": "An expression of a propertyConstraints rule: an integer value; the dotted path of a property of the document type, whose value it takes: an integer or boolean one (1 for true, 0 for false), 0 when the document leaves it out, or a string one compared with a const or another string property; or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out, an integer, or a string for a string property compared with strings; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two; const, a string constant compared with a string property", + "description": "An expression of a propertyConstraints rule: an integer value; the dotted path of a property of the document type, whose value it takes: an integer or boolean one (1 for true, 0 for false), 0 when the document leaves it out, or a string one compared with a const or another string property; or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out, an integer, or a string for a string property compared with strings; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out; const, a string constant compared with a string property", "type": [ "integer", "string", @@ -189,6 +189,18 @@ "power": { "$ref": "#/$defs/propertyConstraintOperandPair" }, + "length": { + "description": "The number of characters of the string property at this path, as maxLength counts them, or 0 when the document leaves it out", + "$ref": "#/$defs/propertyConstraintPath" + }, + "byteLength": { + "description": "The number of UTF-8 bytes of the string property at this path, as maxBytes counts them, or 0 when the document leaves it out", + "$ref": "#/$defs/propertyConstraintPath" + }, + "count": { + "description": "The number of items of the array property at this path, or of bytes of the byte array property, as maxItems counts them, or 0 when the document leaves it out", + "$ref": "#/$defs/propertyConstraintPath" + }, "const": { "description": "A constant, only as one side of an equal or notEqual whose other side is the path of a string property (a string), or of an identifier property or $ownerId (a base58 identifier): a string on its own is a path, and an integer is written as itself", "type": "string" @@ -201,7 +213,7 @@ } }, "propertyConstraintPath": { - "description": "The dotted path of a property of the document type, a nested one through the objects around it: an integer or boolean property when an operand reads its value, any property when present or absent tests it. Or $ownerId, the document's owner, which only a comparison of identifiers reads", + "description": "The dotted path of a property of the document type, a nested one through the objects around it: an integer or boolean property when an operand reads its value, a string property when length or byteLength measures it, an array or byte array property when count counts its items, any property when present or absent tests it. Or $ownerId, the document's owner, which only a comparison of identifiers reads", "type": "string", "pattern": "^(\\$ownerId|[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*)$" }, @@ -2068,7 +2080,7 @@ } }, "propertyConstraints": { - "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, and likewise for identifier properties, whose constants are base58 identifiers, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out (an integer, or a string for a string property compared with strings); add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. A string property the document leaves out equals no constant and no other string property, not even one also left out, unless an ifAbsent gives it a string default ({ \"ifAbsent\": [\"status\", \"open\"] }), which it then reads as, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every const, every present or absent, every arithmetic operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). $ownerId, the document's owner, is an identifier operand (never a property: not in present, absent or an integer operand, and not on an indexOnly type), and a transfer or a purchase, which gives the document a new owner, is refused when it would break a rule reading it. The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", + "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, and likewise for identifier properties, whose constants are base58 identifiers, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out (an integer, or a string for a string property compared with strings); add or multiply, two or more operands; subtract, divide, modulo or power, exactly two; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out ({ \"lessThanOrEqual\": [{ \"count\": \"tags\" }, \"maxTags\"] }). A string property the document leaves out equals no constant and no other string property, not even one also left out, unless an ifAbsent gives it a string default ({ \"ifAbsent\": [\"status\", \"open\"] }), which it then reads as, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property length or byteLength measures a string property, every property count counts an array or byte array property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every const, every present or absent, every arithmetic operator and every operand, a size included; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). $ownerId, the document's owner, is an identifier operand (never a property: not in present, absent or an integer operand, and not on an indexOnly type), and a transfer or a purchase, which gives the document a new owner, is refused when it would break a rule reading it. The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", "type": "object", "propertyNames": { "pattern": "^[a-zA-Z0-9_]{1,64}$" diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index 3a48ec042cd..c13c0486e81 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -1880,8 +1880,10 @@ pub(super) fn validate_encrypted_for_declarations( /// Reads the `propertyConstraints` keyword onto the document type and checks /// every property its rules read: by its value, an integer or boolean /// property of the type (a nested one named by its dotted path, as the -/// flattened map names it); by its presence, a property of any type, an object included; either -/// way one that is neither transient nor inside a transient object. A +/// flattened map names it); by its `length` or `byteLength`, a string +/// property; by its `count`, an array or byte array property; by its presence, +/// a property of any type, an object included; any way one that is neither +/// transient nor inside a transient object. A /// transient value is never stored, so a stored document could not be held to /// a rule reading one. The declaration's shape ([`parse_property_constraints`]) and these reads are /// checked on every parse; under full validation, the limits too: at most @@ -1967,6 +1969,8 @@ fn apply_property_constraints_v0( PropertyRead::Value => "reads", PropertyRead::Presence => "tests the presence of", PropertyRead::Text | PropertyRead::Identifier => "compares", + PropertyRead::Length => "measures", + PropertyRead::Count => "counts the items of", }; match read { PropertyRead::Value => match document_type @@ -2048,6 +2052,59 @@ fn apply_property_constraints_v0( ))); } }, + PropertyRead::Length => match document_type + .flattened_properties + .get(path) + .map(|property| &property.property_type) + { + Some(DocumentPropertyType::String(_)) => {} + Some(other) => { + return Err(structure_error(format!( + "rule \"{name}\" measures the length of \"{path}\", which has type {}, \ + not string: count gives the items of an array or byte array", + other.name() + ))); + } + None => { + return Err(structure_error(format!( + "rule \"{name}\" measures the length of \"{path}\", which is not a \ + string property of the document type (a nested one is named by its \ + dotted path)" + ))); + } + }, + PropertyRead::Count => match document_type + .flattened_properties + .get(path) + .map(|property| &property.property_type) + { + Some( + DocumentPropertyType::TypedArray(_) + | DocumentPropertyType::ByteArray(_) + | DocumentPropertyType::Array(_) + | DocumentPropertyType::VariableTypeArray(_), + ) => {} + Some(DocumentPropertyType::String(_)) => { + return Err(structure_error(format!( + "rule \"{name}\" counts the items of \"{path}\", which has type \ + string, not array: length or byteLength gives the size of a string" + ))); + } + Some(other) => { + return Err(structure_error(format!( + "rule \"{name}\" counts the items of \"{path}\", which has type {}, \ + not array or byteArray", + other.name() + ))); + } + None => { + return Err(structure_error(format!( + "rule \"{name}\" counts the items of \"{path}\", which is not an array \ + or byte array property of the document type (a nested one is named \ + by its dotted path)" + ))); + } + }, PropertyRead::Identifier => match document_type .flattened_properties .get(path) diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs index 96717ba8149..ecbd320f82a 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs @@ -1142,6 +1142,10 @@ fn should_check_the_grammar_with_the_meta_schema_and_the_parser() { json!({ "rule": { "in": ["state", ["open", 2]] } }), json!({ "rule": { "in": ["state", ["open"]] } }), json!({ "rule": { "in": ["state", ["open", "open"]] } }), + json!({ "rule": { "lessThan": [{ "length": 5 }, 10] } }), + json!({ "rule": { "lessThan": [{ "count": ["counts"] }, 10] } }), + json!({ "rule": { "lessThan": [{ "size": "note" }, 10] } }), + json!({ "rule": { "lessThan": [{ "length": "note", "count": "counts" }, 10] } }), ] { let registered = parse_order(rules.clone(), true); assert!( @@ -1295,3 +1299,145 @@ fn should_refuse_adding_removing_or_changing_rules_on_update() { .expect("the update is judged"); assert!(result.is_valid(), "{:?}", result.errors); } + +/// `length` and `byteLength` measure a string property and `count` counts the +/// items of an array property, nested ones included, on both paths. +#[test] +fn should_measure_strings_and_count_arrays_on_both_paths() { + let rules = json!({ + "noteFitsQuantity": { + "lessThanOrEqual": [{ "length": "note" }, { "multiply": ["quantity", 10] }] + }, + "tagWithinBytes": { "lessThanOrEqual": [{ "byteLength": "meta.tag" }, 20] }, + "countsPerUnit": { "lessThanOrEqual": [{ "count": "counts" }, "quantity"] } + }); + for full_validation in [true, false] { + let document_type = parse_order(rules.clone(), full_validation) + .unwrap_or_else(|e| panic!("full_validation {full_validation}: should parse: {e}")); + let constraints = document_type.property_constraints(); + assert_eq!( + constraints["noteFitsQuantity"].property_reads(), + [ + ("note", PropertyRead::Length), + ("quantity", PropertyRead::Value) + ] + ); + assert_eq!( + constraints["tagWithinBytes"].property_reads(), + [("meta.tag", PropertyRead::Length)] + ); + assert_eq!( + constraints["countsPerUnit"].property_reads(), + [ + ("counts", PropertyRead::Count), + ("quantity", PropertyRead::Value) + ] + ); + } + + // A byte array counts its bytes: here a signature is left out or 64 or 65 + // bytes long + let schema = platform_value!({ + "type": "object", + "properties": { + "signature": { + "type": "array", + "byteArray": true, + "maxItems": 65, + "position": 0 + } + }, + "propertyConstraints": { + "signatureLength": { "in": [{ "count": "signature" }, [0, 64, 65]] } + }, + "additionalProperties": false + }); + for full_validation in [true, false] { + let document_type = + parse_dispatched(schema.clone(), PlatformVersion::latest(), full_validation) + .expect("a rule may count the bytes of a byte array"); + assert_eq!( + document_type.property_constraints()["signatureLength"].property_reads(), + [("signature", PropertyRead::Count)] + ); + } +} + +/// A size reads a property of the type of its measure, stored: `length` and +/// `byteLength` a string, `count` an array or a byte array. +#[test] +fn should_hold_a_size_to_the_property_it_measures() { + for (operand, needle) in [ + ( + json!({ "length": "counts" }), + "measures the length of \"counts\", which has type array, not string: count gives \ + the items of an array or byte array", + ), + ( + json!({ "byteLength": "buyerId" }), + "measures the length of \"buyerId\", which has type identifier, not string", + ), + ( + json!({ "length": "meta" }), + "measures the length of \"meta\", which is not a string property of the document type", + ), + ( + json!({ "byteLength": "$ownerId" }), + "measures the length of \"$ownerId\", which is not a string property", + ), + ( + json!({ "count": "note" }), + "counts the items of \"note\", which has type string, not array: length or \ + byteLength gives the size of a string", + ), + ( + json!({ "count": "buyerId" }), + "counts the items of \"buyerId\", which has type identifier, not array or byteArray", + ), + ( + json!({ "count": "rush" }), + "counts the items of \"rush\", which has type boolean, not array or byteArray", + ), + ( + json!({ "count": "meta.missing" }), + "counts the items of \"meta.missing\", which is not an array or byte array property", + ), + ] { + for full_validation in [true, false] { + expect_structure_error( + parse_order( + json!({ "rule": { "lessThan": [operand.clone(), "price"] } }), + full_validation, + ), + needle, + ); + } + } + + // A transient value is never stored, so no rule may measure one + for (transient, operand, path) in [ + ("note", json!({ "length": "note" }), "note"), + ("meta", json!({ "byteLength": "meta.tag" }), "meta.tag"), + ("counts", json!({ "count": "counts" }), "counts"), + ] { + let verb = if operand.get("count").is_some() { + "counts the items of" + } else { + "measures" + }; + let schema = order_schema( + Some(json!({ "rule": { "lessThan": [operand, "price"] } })), + Some(transient), + ); + for full_validation in [true, false] { + expect_structure_error( + parse_dispatched( + schema_value(schema.clone()), + PlatformVersion::latest(), + full_validation, + ), + &format!("{verb} \"{path}\", which is transient or inside a transient object"), + ); + } + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs index 21dc72728f1..04081b19672 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs @@ -34,9 +34,13 @@ //! //! An operand is an integer value, the dotted path of an integer or boolean //! property (a boolean reads as 1 for true and 0 for false), or an object with -//! one key: an arithmetic operator over its operands, or `ifAbsent`, a -//! property with the value it takes when the document leaves it out. A -//! property named on its own takes 0 when absent. A string constant is written +//! one key: an arithmetic operator over its operands, `ifAbsent`, a property +//! with the value it takes when the document leaves it out, or a size: +//! `length` and `byteLength`, the characters and the UTF-8 bytes of a string +//! property, and `count`, the items of an array or byte array property. A +//! property named on its own takes 0 when absent, and so does the size of one +//! (`{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }`). A string +//! constant is written //! `{ "const": "closed" }`, since a string on its own is a path; `equal` and //! `notEqual` compare one with a string property, or two bare paths naming //! string properties with each other, and an `in` whose values are strings @@ -83,11 +87,15 @@ const NOT: &str = "not"; const PRESENT: &str = "present"; const ABSENT: &str = "absent"; const IN: &str = "in"; +const LENGTH: &str = "length"; +const BYTE_LENGTH: &str = "byteLength"; +const COUNT: &str = "count"; /// The operand key of a string constant: `{ "const": "closed" }`. const CONST: &str = "const"; /// Every key an operand object may hold, for the errors. -const OPERAND_KEYS: &str = "add, subtract, multiply, divide, modulo, power or ifAbsent"; +const OPERAND_KEYS: &str = + "add, subtract, multiply, divide, modulo, power, ifAbsent, length, byteLength or count"; /// The deepest a condition or an operand may sit in its rule: the rule's own /// condition at depth 0, and each operand of a comparison, and each condition @@ -155,6 +163,39 @@ impl ConstraintComparison { } } +/// What a size operand measures of the property it names. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SizeMeasure { + /// `length`: the characters of a string property, as `maxLength` counts + /// them. + Length, + /// `byteLength`: the UTF-8 bytes of a string property, as `maxBytes` + /// counts them. + ByteLength, + /// `count`: the items of an array property, or the bytes of a byte array + /// property, as `maxItems` counts them. + Count, +} + +impl SizeMeasure { + /// The operand key declaring it. + pub fn wire_name(self) -> &'static str { + match self { + SizeMeasure::Length => LENGTH, + SizeMeasure::ByteLength => BYTE_LENGTH, + SizeMeasure::Count => COUNT, + } + } + + /// How an operand of this measure reads the property it names. + fn read(self) -> PropertyRead { + match self { + SizeMeasure::Length | SizeMeasure::ByteLength => PropertyRead::Length, + SizeMeasure::Count => PropertyRead::Count, + } + } +} + /// An integer expression, one side of a rule or an operand inside one. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ConstraintExpression { @@ -164,6 +205,10 @@ pub enum ConstraintExpression { /// for true, 0 for false), or `if_absent` when the document leaves it out: /// 0 for a path on its own, the declared value for an `ifAbsent` operand. Property { path: String, if_absent: i128 }, + /// `length`, `byteLength` or `count`: the size of the property at the + /// dotted `path`, as `measure` counts it, or 0 when the document leaves it + /// out. + Size { measure: SizeMeasure, path: String }, /// `add`: the sum of two or more operands. Add(Vec), /// `multiply`: the product of two or more operands. @@ -192,6 +237,9 @@ impl ConstraintExpression { /// fractional part as an integer, which the document could not be stored /// with anyway) that fits an `i128` /// ([`PropertyConstraintViolation::Overflow`] otherwise); + /// * a size is never a fault: a property the document leaves out, or sets + /// to null, has size 0, and so does a value of another type than the + /// one measured, which the schema validation reported first refuses; /// * `add` and `multiply` fold their operands from the left, so an overflow /// on the way is a fault even when a later operand would bring the result /// back in range; @@ -209,6 +257,11 @@ impl ConstraintExpression { ConstraintExpression::Property { path, if_absent } => { property_value(data, path, *if_absent) } + ConstraintExpression::Size { measure, path } => { + // A size fits a `usize`, which always fits an `i128` + i128::try_from(property_size(data, path, *measure)) + .map_err(|_| PropertyConstraintViolation::Overflow) + } ConstraintExpression::Add(operands) => { operands.iter().try_fold(0i128, |sum, operand| { sum.checked_add(operand.evaluate(data)?) @@ -260,7 +313,9 @@ impl ConstraintExpression { /// The nodes of the expression: this one, and those of its operands. pub fn node_count(&self) -> usize { 1 + match self { - ConstraintExpression::Value(_) | ConstraintExpression::Property { .. } => 0, + ConstraintExpression::Value(_) + | ConstraintExpression::Property { .. } + | ConstraintExpression::Size { .. } => 0, ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) => { operands.iter().map(ConstraintExpression::node_count).sum() } @@ -275,7 +330,7 @@ impl ConstraintExpression { fn reads_property(&self) -> bool { match self { ConstraintExpression::Value(_) => false, - ConstraintExpression::Property { .. } => true, + ConstraintExpression::Property { .. } | ConstraintExpression::Size { .. } => true, ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) => { operands.iter().any(ConstraintExpression::reads_property) } @@ -288,12 +343,13 @@ impl ConstraintExpression { } } - /// Appends the properties the expression reads, each by its value, to - /// `reads`, in the order it reads them. + /// Appends the properties the expression reads, each by its value or its + /// size, to `reads`, in the order it reads them. fn collect_property_reads<'a>(&'a self, reads: &mut Vec<(&'a str, PropertyRead)>) { match self { ConstraintExpression::Value(_) => {} ConstraintExpression::Property { path, .. } => reads.push((path, PropertyRead::Value)), + ConstraintExpression::Size { measure, path } => reads.push((path, measure.read())), ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) => { for operand in operands { operand.collect_property_reads(reads); @@ -323,6 +379,10 @@ pub enum PropertyRead { /// By its value, compared with identifier constants: an identifier /// property. Identifier, + /// By its size, in a `length` or `byteLength` operand: a string property. + Length, + /// By its size, in a `count` operand: an array or byte array property. + Count, } /// What a comparison of equality compares when it is not integers: strings or @@ -1525,6 +1585,21 @@ fn parse_expression( } ConstraintExpression::Power(Box::new(base), Box::new(exponent)) } + // What the path names is checked against the parsed document type + LENGTH | BYTE_LENGTH | COUNT => { + let Some(path) = operands.as_text() else { + return Err(format!("at {at} must name a property path")); + }; + let measure = match key { + LENGTH => SizeMeasure::Length, + BYTE_LENGTH => SizeMeasure::ByteLength, + _ => SizeMeasure::Count, + }; + ConstraintExpression::Size { + measure, + path: path.to_string(), + } + } CONST => { at.truncate(parent); return Err(format!( @@ -1653,6 +1728,27 @@ fn property_value( } } +/// The size of the property at `path` in `data`, as `measure` counts it: 0 +/// when the document leaves it out or sets it to null, and for a value of +/// another type than `measure` reads, which the schema validation reported +/// before the rules refuses. A byte array counts its bytes, whichever form the +/// document gives them in. +fn property_size(data: &Value, path: &str, measure: SizeMeasure) -> usize { + let Ok(Some(value)) = data.get_optional_value_at_path(path) else { + return 0; + }; + match (measure, value) { + (SizeMeasure::Length, Value::Text(text)) => text.chars().count(), + (SizeMeasure::ByteLength, Value::Text(text)) => text.len(), + (SizeMeasure::Count, Value::Array(items)) => items.len(), + (SizeMeasure::Count, Value::Bytes(bytes)) => bytes.len(), + (SizeMeasure::Count, Value::Bytes20(_)) => 20, + (SizeMeasure::Count, Value::Bytes32(_) | Value::Identifier(_)) => 32, + (SizeMeasure::Count, Value::Bytes36(_)) => 36, + _ => 0, + } +} + /// `base` to the power `exponent`, exactly. An exponent too large for /// `checked_pow` leaves only the bases 0, 1 and -1 in range. fn power(base: i128, exponent: i128) -> Result { diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs index fd4edbb2ed2..e5191e8f379 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs @@ -1987,3 +1987,217 @@ fn should_read_a_boolean_as_one_or_zero() { Some(PropertyConstraintViolation::NotMet) ); } + +// ── sizes ─────────────────────────────────────────────────────────────── + +/// `length`, `byteLength` and `count` are operands naming a property, one node +/// each, read by their size. +#[test] +fn should_parse_the_size_operands() { + for (key, measure, read) in [ + ("length", SizeMeasure::Length, PropertyRead::Length), + ("byteLength", SizeMeasure::ByteLength, PropertyRead::Length), + ("count", SizeMeasure::Count, PropertyRead::Count), + ] { + let rule = parse_rule_value(platform_value!({ + "lessThanOrEqual": [{ key: "meta.body" }, "limit"] + })); + assert_eq!( + rule, + PropertyConstraint::Compare { + comparison: ConstraintComparison::LessThanOrEqual, + left: ConstraintExpression::Size { + measure, + path: "meta.body".to_string(), + }, + right: property("limit"), + }, + "{key}" + ); + assert_eq!(measure.wire_name(), key); + assert_eq!(rule.node_count(), 3, "{key}"); + assert_eq!( + rule.property_reads(), + [("meta.body", read), ("limit", PropertyRead::Value)], + "{key}" + ); + } + + // A size reads a property, so it alone keeps a comparison with a literal + // meaningful, inside arithmetic and in an `in` too + let rule = parse_rule_value(platform_value!({ + "in": [{ "add": [{ "count": "tags" }, 1] }, [1, 2, 3]] + })); + assert_eq!(rule.property_reads(), [("tags", PropertyRead::Count)]); + assert_eq!(rule.node_count(), 7); + + // Two measures of one property are different conditions + let rules = parse(platform_value!({ + "rule": { + "anyOf": [ + { "lessThanOrEqual": [{ "length": "title" }, 10] }, + { "lessThanOrEqual": [{ "byteLength": "title" }, 10] } + ] + } + })) + .expect("parses"); + assert_eq!(rules["rule"].repeated_condition(), None); +} + +#[test] +fn should_refuse_a_malformed_size_operand() { + for (operand, needle) in [ + ( + platform_value!({ "length": 5 }), + "at lessThan[0].length must name a property path", + ), + ( + platform_value!({ "byteLength": ["title"] }), + "at lessThan[0].byteLength must name a property path", + ), + ( + platform_value!({ "count": { "add": ["a", 1] } }), + "at lessThan[0].count must name a property path", + ), + ( + platform_value!({ "size": "title" }), + "names \"size\", which is not one of add, subtract, multiply, divide, modulo, \ + power, ifAbsent, length, byteLength or count", + ), + ] { + expect_refusal( + platform_value!({ "rule": { "lessThan": [operand, 10] } }), + needle, + ); + } +} + +/// `length` counts characters, as `maxLength` does, and `byteLength` UTF-8 +/// bytes, as `maxBytes` does. +#[test] +fn should_measure_a_string_in_characters_and_in_bytes() { + for (text, characters, bytes) in [ + ("", 0, 0), + ("hello", 5, 5), + ("héllo", 5, 6), + ("日本", 2, 6), + ("👍🏽", 2, 8), + ] { + let values = data(&[("title", Value::Text(text.to_string()))]); + assert_eq!( + evaluate(platform_value!({ "length": "title" }), &values), + Ok(characters), + "{text:?}" + ); + assert_eq!( + evaluate(platform_value!({ "byteLength": "title" }), &values), + Ok(bytes), + "{text:?}" + ); + } +} + +/// `count` counts the items of an array, and the bytes of a byte array in every +/// form a document gives one in. +#[test] +fn should_count_the_items_of_an_array_and_the_bytes_of_a_byte_array() { + for (value, items) in [ + (Value::Array(vec![]), 0), + ( + Value::Array(vec![ + Value::Text("a".to_string()), + Value::Text("b".to_string()), + Value::Text("c".to_string()), + ]), + 3, + ), + (Value::Bytes(vec![7; 10]), 10), + (Value::Bytes20([7; 20]), 20), + (Value::Bytes32([7; 32]), 32), + (Value::Identifier([7; 32]), 32), + (Value::Bytes36([7; 36]), 36), + ] { + let values = data(&[("tags", value.clone())]); + assert_eq!( + evaluate(platform_value!({ "count": "tags" }), &values), + Ok(items), + "{value:?}" + ); + } +} + +/// A size never faults: a property left out or set to null has size 0, and so +/// does a value of another type, which the schema validation reported first +/// refuses. +#[test] +fn should_take_a_size_of_zero_for_a_property_left_out_or_of_another_type() { + let values = data(&[ + ("empty", Value::Null), + ("number", Value::U64(12345)), + ("title", Value::Text("hello".to_string())), + ("tags", Value::Array(vec![Value::U8(1), Value::U8(2)])), + ]); + for (expression, expected) in [ + (platform_value!({ "length": "missing" }), 0), + (platform_value!({ "byteLength": "empty" }), 0), + (platform_value!({ "count": "meta.missing" }), 0), + (platform_value!({ "length": "number" }), 0), + (platform_value!({ "length": "tags" }), 0), + (platform_value!({ "count": "title" }), 0), + (platform_value!({ "count": "number" }), 0), + ] { + assert_eq!( + evaluate(expression.clone(), &values), + Ok(expected), + "{expression:?}" + ); + } + + // A rule over a size left out holds or not as 0 says + let rule = parse_rule_value(platform_value!({ + "greaterThanOrEqual": [{ "count": "tags" }, 1] + })); + assert_eq!( + rule.violation(&data(&[]), None), + Some(PropertyConstraintViolation::NotMet) + ); +} + +/// A size compares with other properties: here a list holds at most as many +/// tags as its `maxTags`, and a free listing's title is short. +#[test] +fn should_compare_a_size_with_other_properties() { + let tags_within_limit = parse_rule_value(platform_value!({ + "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] + })); + let tags = |count: usize| Value::Array(vec![Value::Text("tag".to_string()); count]); + assert_eq!( + tags_within_limit.violation(&data(&[("tags", tags(2)), ("maxTags", Value::U8(3))]), None), + None + ); + assert_eq!( + tags_within_limit.violation(&data(&[("tags", tags(4)), ("maxTags", Value::U8(3))]), None), + Some(PropertyConstraintViolation::NotMet) + ); + + let short_title_when_free = parse_rule_value(platform_value!({ + "anyOf": [ + { "greaterThan": ["fee", 0] }, + { "lessThanOrEqual": [{ "length": "title" }, 5] } + ] + })); + let listing = + |fee: u64, title: &str| data(&[("fee", Value::U64(fee)), ("title", Value::from(title))]); + assert_eq!( + short_title_when_free.violation(&listing(0, "héllo"), None), + None + ); + assert_eq!( + short_title_when_free.violation(&listing(10, "a long title"), None), + None + ); + assert_eq!( + short_title_when_free.violation(&listing(0, "a long title"), None), + Some(PropertyConstraintViolation::NotMet) + ); +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs index cfa0a56e9cd..07a1b2a2c0c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs @@ -218,6 +218,44 @@ mod property_constraints_tests { }) } + /// An `offer` type with the integers [`set_valid_offer`] fills, a `title`, a + /// typed array of `tags` and a byte array `signature`, and four rules on + /// their sizes: `shortTitle` (at most 10 characters), `titleBytes` (at most + /// 12 UTF-8 bytes), `tagsPerUnit` (no more tags than the quantity) and + /// `signatureLength` (left out, or 64 or 65 bytes). + fn sized_offer_schema() -> Value { + platform_value!({ + "type": "object", + "properties": { + "price": { "type": "integer", "minimum": 0, "position": 0 }, + "fee": { "type": "integer", "minimum": 0, "position": 1 }, + "quantity": { "type": "integer", "minimum": 0, "position": 2 }, + "deposit": { "type": "integer", "minimum": 0, "position": 3 }, + "title": { "type": "string", "maxLength": 40, "position": 4 }, + "tags": { + "type": "array", + "maxItems": 8, + "items": { "type": "string", "maxLength": 16 }, + "position": 5 + }, + "signature": { + "type": "array", + "byteArray": true, + "maxItems": 65, + "position": 6 + } + }, + "required": ["price", "fee", "quantity", "deposit"], + "propertyConstraints": { + "shortTitle": { "lessThanOrEqual": [{ "length": "title" }, 10] }, + "titleBytes": { "lessThanOrEqual": [{ "byteLength": "title" }, 12] }, + "tagsPerUnit": { "lessThanOrEqual": [{ "count": "tags" }, "quantity"] }, + "signatureLength": { "in": [{ "count": "signature" }, [0, 64, 65]] } + }, + "additionalProperties": false + }) + } + /// An offer that meets every rule: (100 + 10) * 2 = 220. fn set_valid_offer(document: &mut Document) { document.set("price", Value::U64(100)); @@ -1375,4 +1413,56 @@ mod property_constraints_tests { && e.violation() == PropertyConstraintViolation::NotMet ); } + + /// Sizes read by real creates: a title too long in characters, one short + /// enough in characters but too long in bytes, more tags than the quantity + /// and a signature of the wrong length are each refused with the rule they + /// break, and an offer meeting all four is stored. + #[tokio::test] + async fn should_judge_the_sizes_of_strings_arrays_and_byte_arrays() { + let mut fixture = OfferFixture::with_schema(sized_offer_schema()); + let tags = |count: usize| Value::Array(vec![Value::Text("tag".to_string()); count]); + + // 12 characters + let result = fixture + .create(|document| document.set("title", Value::from("a long title"))) + .await; + expect_violated(result, "shortTitle", PropertyConstraintViolation::NotMet); + + // 8 characters, 16 bytes + let result = fixture + .create(|document| document.set("title", Value::from("éééééééé"))) + .await; + expect_violated(result, "titleBytes", PropertyConstraintViolation::NotMet); + + // 3 tags for a quantity of 2 + let result = fixture + .create(|document| document.set("tags", tags(3))) + .await; + expect_violated(result, "tagsPerUnit", PropertyConstraintViolation::NotMet); + + // 10 bytes + let result = fixture + .create(|document| document.set("signature", Value::Bytes(vec![7; 10]))) + .await; + expect_violated( + result, + "signatureLength", + PropertyConstraintViolation::NotMet, + ); + assert!(fixture.stored_offers().is_empty()); + + // 4 characters in 5 bytes, 2 tags, a 64-byte signature + assert_matches!( + fixture + .create(|document| { + document.set("title", Value::from("Café")); + document.set("tags", tags(2)); + document.set("signature", Value::Bytes(vec![7; 64])); + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_offers().len(), 1); + } } diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 2fc70a41e01..fa2affdcfec 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1048,8 +1048,11 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// (`equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan`, /// `greaterThanOrEqual`) of two integer expressions built from integer /// literals, paths of integer or boolean properties (a boolean reading as -/// 1 for true and 0 for false) and `add`, `subtract`, `multiply`, -/// `divide`, `modulo` and `power`; `in`, whether an integer expression +/// 1 for true and 0 for false), `add`, `subtract`, `multiply`, +/// `divide`, `modulo` and `power`, and sizes: `length` and `byteLength`, +/// the characters and UTF-8 bytes of a string property, and `count`, the +/// items of an array or byte array property, each 0 for a property the +/// document leaves out; `in`, whether an integer expression /// takes one of two or more distinct integer values; `equal` or `notEqual` /// of a string property and a `{ "const": string }` or of two bare paths /// naming string properties, or `in` of a string property and two or more @@ -1073,7 +1076,9 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// fails), a fault in one that is checked refuses the document whatever the /// others say, and `not` never turns a fault into a pass, so an earlier /// condition guards a later one. The parser checks that every path an -/// operand reads names an integer or boolean property, every path compared +/// operand reads names an integer or boolean property, every path a +/// `length` or `byteLength` measures a string property, every path a +/// `count` counts an array or byte array property, every path compared /// with identifiers an identifier property, every path compared with /// strings a string property (whose `enum`, if it declares one, lists every /// constant it is compared with), and every path `present` or `absent` diff --git a/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs index 43e9ed8adb5..560ff1b3c42 100644 --- a/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs +++ b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs @@ -62,12 +62,13 @@ const PROPERTY_CONSTRAINTS_KEYWORD: &str = "propertyConstraints"; /// the rule's name (its key in `propertyConstraints`), the rule exactly as the /// document type's schema declares it, every property it reads in declared /// order (`kind` is `"value"` for an integer operand, `"presence"` for -/// `present` / `absent`, `"text"` for a string comparison and `"identifier"` -/// for an identifier comparison; `$ownerId` is no property and is not listed), -/// and whether it reads `$ownerId`, which makes a transfer or a purchase answer -/// to it too. Rules are listed in name order, the order consensus checks them -/// in. A document type declaring none gives `[]`, and so does every document -/// type when the SDK's protocol version is below 14. +/// `present` / `absent`, `"text"` for a string comparison, `"identifier"` for +/// an identifier comparison, `"length"` for a `length` or `byteLength` operand +/// and `"count"` for a `count` operand; `$ownerId` is no property and is not +/// listed), and whether it reads `$ownerId`, which makes a transfer or a +/// purchase answer to it too. Rules are listed in name order, the order +/// consensus checks them in. A document type declaring none gives `[]`, and so +/// does every document type when the SDK's protocol version is below 14. /// /// The contract is read from its platform serialization at the SDK's protocol /// version, as `dash_sdk_add_known_contracts` reads it, without re-validating @@ -358,6 +359,8 @@ fn read_kind_name(read: PropertyRead) -> &'static str { PropertyRead::Presence => "presence", PropertyRead::Text => "text", PropertyRead::Identifier => "identifier", + PropertyRead::Length => "length", + PropertyRead::Count => "count", } } diff --git a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs index b5d5c827a37..d2457e3e409 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs @@ -2,8 +2,8 @@ //! created or replaced document's properties to, from protocol version 14 //! onward. //! -//! A rule is a condition: a comparison of integer expressions, a membership -//! test (`in`), a comparison of a string or an identifier property (or +//! A rule is a condition: a comparison of integer expressions (sizes of +//! strings and arrays included), a membership test (`in`), a comparison of a string or an identifier property (or //! `$ownerId`, the document's owner) with constants or with another property //! of its kind, a presence test (`present`, `absent`), or `anyOf`, `allOf` or //! `not` over conditions. Consensus evaluates every rule on each create and @@ -38,7 +38,11 @@ const DOCUMENT_PROPERTY_CONSTRAINTS_TS: &'static str = r#" * - `ifAbsent`: a property path and the integer it takes when left out; * - `add` and `multiply` over two or more operands, `subtract`, `divide`, * `modulo` and `power` over exactly two. Arithmetic is exact over 128-bit - * integers; `divide` and `modulo` are Euclidean. + * integers; `divide` and `modulo` are Euclidean; + * - `length` and `byteLength`: the characters (as `maxLength` counts them) + * and the UTF-8 bytes of a string property; `count`: the items of an array + * property, or the bytes of a byte array property. Each is 0 when the + * document leaves the property out. */ export type PropertyConstraintExpression = | number @@ -50,7 +54,10 @@ export type PropertyConstraintExpression = | { subtract: [PropertyConstraintExpression, PropertyConstraintExpression] } | { divide: [PropertyConstraintExpression, PropertyConstraintExpression] } | { modulo: [PropertyConstraintExpression, PropertyConstraintExpression] } - | { power: [PropertyConstraintExpression, PropertyConstraintExpression] }; + | { power: [PropertyConstraintExpression, PropertyConstraintExpression] } + | { length: string } + | { byteLength: string } + | { count: string }; /** * One side of a comparison of strings or identifiers. @@ -97,9 +104,16 @@ export type PropertyConstraintCondition = /** * How a rule reads a property: `value` as an integer operand, `presence` in * `present` or `absent`, `text` compared with strings, `identifier` compared - * with identifiers. + * with identifiers, `length` by the size of a string (`length` or + * `byteLength`), `count` by the items of an array or byte array. */ -export type PropertyConstraintReadKind = 'value' | 'presence' | 'text' | 'identifier'; +export type PropertyConstraintReadKind = + | 'value' + | 'presence' + | 'text' + | 'identifier' + | 'length' + | 'count'; /** * A single `propertyConstraints` rule of a document type. @@ -169,6 +183,8 @@ fn read_kind_name(read: PropertyRead) -> &'static str { PropertyRead::Presence => "presence", PropertyRead::Text => "text", PropertyRead::Identifier => "identifier", + PropertyRead::Length => "length", + PropertyRead::Count => "count", } } diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts index b18dae30a63..6352f9153ce 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts @@ -167,6 +167,63 @@ describe('DataContract: propertyConstraints (v14)', () => { expect(byType.get('offer')).to.have.length(5); }); + it('should report sizes as length and count reads, and check them', () => { + const rules = { + titleBytes: { lessThanOrEqual: [{ byteLength: 'title' }, 12] }, + tagsWithinLimit: { lessThanOrEqual: [{ count: 'tags' }, 'maxTags'] }, + }; + const contract = buildContract({ + listing: { + type: 'object', + properties: { + title: { type: 'string', maxLength: 40, position: 0 }, + tags: { + type: 'array', + maxItems: 8, + items: { type: 'string', maxLength: 16 }, + position: 1, + }, + maxTags: { type: 'integer', minimum: 0, maximum: 8, position: 2 }, + }, + additionalProperties: false, + propertyConstraints: rules, + }, + }); + + expect(contract.documentTypePropertyConstraints('listing')).to.deep.equal([ + { + name: 'tagsWithinLimit', + rule: rules.tagsWithinLimit, + reads: [{ path: 'tags', kind: 'count' }, { path: 'maxTags', kind: 'value' }], + readsOwner: false, + }, + { + name: 'titleBytes', + rule: rules.titleBytes, + reads: [{ path: 'title', kind: 'length' }], + readsOwner: false, + }, + ]); + + const listing = (properties: Record) => new wasm.Document({ + properties, + documentTypeName: 'listing', + dataContractId: contract.id, + ownerId, + revision: BigInt(1), + }); + expect(contract.checkDocumentPropertyConstraints( + listing({ title: 'Café', tags: ['a', 'b'], maxTags: 2 }), + )).to.equal(undefined); + expect(contract.checkDocumentPropertyConstraints( + listing({ title: 'Café', tags: ['a', 'b', 'c'], maxTags: 2 }), + )).to.deep.include({ rule: 'tagsWithinLimit', violation: 'NotMet' }); + // 8 characters, 16 bytes + expect(contract.checkDocumentPropertyConstraints( + listing({ title: 'éééééééé' }), + )).to.deep.include({ rule: 'titleBytes', violation: 'NotMet' }); + }); + it('should report integer literals past Number.MAX_SAFE_INTEGER exactly, as bigint', () => { const big = 9007199254740993n; // 2 ** 53 + 1, which a number rounds const rules = {