diff --git a/.github/package-filters/rs-packages-no-workflows.yml b/.github/package-filters/rs-packages-no-workflows.yml index 90835d0429f..3d67337a20d 100644 --- a/.github/package-filters/rs-packages-no-workflows.yml +++ b/.github/package-filters/rs-packages-no-workflows.yml @@ -106,6 +106,9 @@ rs-sdk-trusted-context-provider: &sdk_trusted_context_provider - *context_provider - *dpp +dash-sdk-contract: + - packages/rs-dash-sdk-contract/** + dapi-grpc: &dapi_grpc - packages/rs-platform-version/** - packages/rs-dash-platform-macros/** diff --git a/.github/package-filters/rs-packages.yml b/.github/package-filters/rs-packages.yml index 6fae2aa84ab..74b7d9973be 100644 --- a/.github/package-filters/rs-packages.yml +++ b/.github/package-filters/rs-packages.yml @@ -126,6 +126,10 @@ rs-sdk-trusted-context-provider: &sdk_trusted_context_provider - *context_provider - *dpp +dash-sdk-contract: + - .github/workflows/tests* + - packages/rs-dash-sdk-contract/** + dapi-grpc: &dapi_grpc - .github/workflows/tests* - packages/rs-platform-version/** diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index f6a979e1402..9a7035f177e 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -239,6 +239,17 @@ jobs: done done + # The contract-author declaration crate is a guest dependency: contract + # packages compile it for WebAssembly without std. Whole-workspace + # builds unify features and would hide a std leak, so check the + # standalone no-std graph on the no-std-library wasm target and run the + # crate's tests without default features. + - name: Check guest declaration cut + run: | + rustup target add wasm32v1-none + cargo check -p dash-sdk-contract --no-default-features --target wasm32v1-none --locked + cargo test -p dash-sdk-contract --no-default-features --locked + - name: Detect immutable structure changes if: github.event_name == 'pull_request' run: | @@ -419,6 +430,7 @@ jobs: --package token-history-contract \ --package wallet-utils-contract \ --package keyword-search-contract \ + --package dash-sdk-contract \ --all-features \ --locked \ -E 'not test(~shield) and (not binary_id(=drive-abci::strategy_tests) or test(~comprehensive_mixed_operations) or test(~process_proposal_collision))' diff --git a/Cargo.lock b/Cargo.lock index 05bd8b6e1b6..2251afb9da3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1746,6 +1746,13 @@ dependencies = [ "zeroize", ] +[[package]] +name = "dash-sdk-contract" +version = "4.2.0-dev.11" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "dash-spv" version = "0.45.0" diff --git a/Cargo.toml b/Cargo.toml index 723983f6975..d6377dd43b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ resolver = "2" members = [ "packages/dapi-grpc", "packages/rs-dash-platform-macros", + "packages/rs-dash-sdk-contract", "packages/rs-dpp", "packages/rs-drive", "packages/rs-platform-value", diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 9d130158ca9..f3909b50f60 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -109,6 +109,10 @@ - [Binding Patterns](wasm/binding-patterns.md) - [Error Macros](wasm/error-macros.md) +# DashVM + +- [Contract Declarations and the Author API](dashvm/contract-declarations.md) + --- # Appendix diff --git a/book/src/contributing/coding-conventions.md b/book/src/contributing/coding-conventions.md index 19b6fda27e1..66bc0461a23 100644 --- a/book/src/contributing/coding-conventions.md +++ b/book/src/contributing/coding-conventions.md @@ -47,6 +47,7 @@ crate that has what the change needs, and no lower. | A client API | `packages/rs-sdk` | Follow the query checklist in `packages/rs-sdk/README.md`. | | A JavaScript binding | `packages/wasm-dpp2`, `packages/wasm-sdk` | Mirror the Rust shape; never validate. See `packages/wasm-dpp2/CONVENTIONS.md`. | | Mobile orchestration (sync, identity registration, DashPay) | `packages/rs-platform-wallet` | The FFI crates and the Swift and Kotlin SDKs marshal; they do not decide. | +| A contract-author declaration or diagnostic | `packages/rs-dash-sdk-contract` | Builds for `wasm32v1-none` with `--no-default-features`; native limits are not duplicated there, they surface through `dash-contract-build`. See [Contract Declarations](../dashvm/contract-declarations.md). | Three boundaries are enforced by CI and worth knowing by name: diff --git a/book/src/dashvm/contract-declarations.md b/book/src/dashvm/contract-declarations.md new file mode 100644 index 00000000000..59efcaea73e --- /dev/null +++ b/book/src/dashvm/contract-declarations.md @@ -0,0 +1,335 @@ +# Contract Declarations and the Author API + +> **Status:** specification landed on the development branch as the crate +> `dash-sdk-contract` (`packages/rs-dash-sdk-contract`, import +> `dash_sdk_contract`). The attribute spellings, the export symbol scheme, the +> method, module, interface and rule name grammars and the diagnostic codes are +> provisional under the shared allocation register for the Rust macro grammar. +> The proc macros, generated persistence wrappers, host context and runtime are +> later deliverables of the same workstream and consume the types described +> here. The native translation and host validation crate, +> `dash-contract-build`, lands separately. + +A DashVM contract is ordinary Rust. A struct marked `#[persistent]` is stored +as Platform documents; indexes are declared on that struct; only functions +marked `#[entry]` are callable from outside, and `pub` alone exports nothing. +This chapter describes the declaration model behind those attributes: what +the grammar admits, how declarations are identified, what the validator +checks and what it leaves to the native host, and what the canonical manifest +looks like. + +The confirmed policy the model implements: + +- Detached Rust values do not save automatically. Explicit insert and edit + operations and successful exported mutable-receiver wrappers stage writes in + the outer transaction. No save on `Drop`. +- Simple persistence, index and rule attributes plus typed builders for + advanced native features produce one canonical manifest. Unsupported or + conflicting declarations are rejected; native validation remains the + authority. No unrestricted database paths. +- Existing versioned DPP and Drive index-update rules are reused exactly. + There is no index backfill or migration subsystem. +- Private Rust fields and access-control checks are not encryption. The + private document store stays in the capability catalogue with its interface + disabled until encryption, key control, query visibility and proof behaviour + are specified. +- A contested-index award is a native action. Contracts parameterize + contested indexes only through the supported native declarations; no guest + code, guard or predicate runs on the award. + +## The sketch + +The issue's `Score` example, in the shape the model accepts: + +```rust +use dash_sdk_contract::prelude::*; + +#[persistent(collection = "scores", schema = 1, write = "contract")] +#[index(name = "by_class", fields(class = "asc"), count, sum = "points")] +#[index(name = "by_owner", fields(owner = "asc"))] +#[index(name = "ranking", fields(class = "asc", points = "asc"), ranked_count)] +pub struct Score { + #[document_id] + pub id: DocumentId, + #[field(position = 0, max_chars = 64)] + pub class: String, + #[field(position = 1, min = 0, max = 1_000_000)] + pub points: i64, + #[field(position = 2, refers_to = "identity")] + pub owner: IdentityId, +} + +impl Score { + #[entry(name = "score.add")] + pub fn add(&mut self, ctx: &mut Context, delta: i64) -> Result<()> { /* ... */ } +} + +#[entry(name = "score.total", read_only)] +pub fn class_total(ctx: &Context, class: String) -> Result { /* ... */ } +``` + +One correction to the original sketch: the `ranking` index is declared with +`points = "asc"`, not `"desc"`. The native document meta-schema admits only +ascending index properties; a descending walk is a per-query choice, not an +index property. `fields(points = "desc")` is rejected with +`InvalidOptionValue`, and the diagnostic says why. + +Until the macros ship, the same declaration is written through builders: + +```rust +let scores = CollectionSpec::documents(CollectionName::new("scores")?) + .write(WritePolicy::Contract) + .document_id_field("id") + .field(FieldSpec::new(PropertyName::new("class")?, 0, FieldType::string(64))) + .field(FieldSpec::new( + PropertyName::new("points")?, + 1, + FieldType::bounded_integer(IntegerWidth::I64, 0, 1_000_000), + )) + .field(FieldSpec::new(PropertyName::new("owner")?, 2, FieldType::identity())) + .index( + IndexSpec::new(IndexName::new("by_class")?, vec![PropertyPath::new("class")?]) + .count() + .sum(PropertyName::new("points")?), + ); + +let manifest = ContractDeclaration::new() + .collection(scores) + .entry( + EntrySpec::new(MethodName::new("score.add")?) + .receiver(Receiver::Mut(CollectionName::new("scores")?)) + .param("delta", ValueType::Integer(IntegerWidth::I64)), + ) + .validate()?; +``` + +Attributes and builders feed the same `ContractDeclaration`. A builder may +restate an attribute declaration verbatim, or add indexes to an +attribute-declared collection, but a builder that changes what an attribute +said is a `ConflictingDeclaration` naming both origins. + +## The attribute grammar + +The grammar is data: `dash_sdk_contract::grammar::ATTRIBUTES` lists every +attribute, its options and the value each option accepts. The proc macros +parse against that table, `grammar::check_keys` reports grammar diagnostics +from it, and an integration test reads this chapter and checks the table +below against it, so the three cannot drift. An option not in the table is `UnknownOption`; a value outside a closed +set is `InvalidOptionValue`; a repeated option is `DuplicateOption`; a +missing required option is `MissingOption`. Nothing is ignored. + +| Attribute | On | Options | +|---|---|---| +| `persistent` | struct | `collection` (required), `schema` (integer, default 1), `write` (`any` / `owner` / `contract`), `mutable`, `deletable`, `keep_history`, `keep_transfer_history`, `keep_purchase_history`, `keep_pricing_history`, `transferable`, `trade` (`none` / `direct_purchase`), `security_level` (`critical` / `high` / `medium`), `encryption_key` and `decryption_key` (`unique` / `multiple` / `multiple_reference_to_latest`), `count`, `range_count`, `sum = ""`, `range_sum`, `average = ""`, `range_average`, `index_only`, `requires = ["$createdAt", ...]` (system properties every document carries), `store` (`public` / `private`) | +| `singleton` | struct | `collection` (required), `schema`, `write`, `security_level`, `encryption_key`, `decryption_key`, `requires = [...]`, `store` | +| `token_cost` | struct, repeatable | `on` (an ordinary action, required), `token_position` (required), `amount` (required), `contract` (base58), `effect` (`transfer_to_contract_owner` / `burn`), `gas_paid_by` (`document_owner` / `contract_owner` / `prefer_contract_owner`) | +| `index` | struct, repeatable | `name` (required), `fields( = "asc", ...)` (required), `unique`, `null_searchable` (default true), `contested(field_matches( = ""), resolution = "masternode_vote", description)`, `count` or `count = "offset"`, `range_count`, `sum = ""`, `range_sum`, `average = ""`, `range_average`, `ranked_count` or `ranked_count = ["", ...]`, `ranked_sum`, `ranked_average`, `time_range(on, range_secs, step_secs, phase_secs, ttl_secs)`, `terminal = ""`, `preallocated`, `skip_if_absent` | +| `field` | field | `position` (required), `max_chars`, `min_chars`, `max_len`, `min_len`, `min`, `max`, `values = [...]`, `required` (default true), `transient`, `refers_to` (`identity` / `contract` / `token` / `permanent_document` / `identity_public_key`), `document_type`, `contract`, `agreement( = "")`, `key_id_field`, `description` | +| `document_id` | field | none | +| `entry` | fn | `name` (required), `read_only`, `module` | +| `rule` | struct, repeatable | `name` (required), `on = [...]` (ordinary actions, required), exactly one of `guard = ""` or `predicate = "::"` | +| `contract` | crate | `receipts` (`stored` / `disabled`, default stored), `requires = [...]` (`acl`, `randomness`) | +| `module` | module | `name` (required), `uses = [...]` | +| `interface` | trait | `name` (required), `provider` (required) | + +The ordinary actions are `create`, `replace`, `delete`, `transfer`, +`purchase` and `update_price`. There is no `award`: `on = "award"` is +`InvalidOptionValue` and the reason names the native award rule. + +## Identity + +A declaration is identified by its declared name, never by the Rust path, +impl block, source file or declaration order that produced it. + +| Identity | Grammar | Where the rule comes from | +|---|---|---| +| Collection | `^[a-zA-Z0-9_-]{1,64}$` | native document type name | +| Property | `^[a-zA-Z0-9_-]{1,64}$`, plus its position | meta-schema property names | +| Index | 1 to 32 characters, unique within its collection | meta-schema index name | +| Method | `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$`, at most 64 bytes, unique contract-wide | provisional | +| Module, interface | `^[a-z0-9_]{1,64}$` | provisional, aligned with the bundle validation crate | +| Rule | `^[a-z][a-z0-9_]{0,63}$`, unique within its collection | provisional | + +An entry's WASM export symbol is `dash_entry_` followed by the method name +verbatim (`dash_entry_score.add`). The mapping is a prefix plus the identity, +so it is injective; WebAssembly export names are arbitrary UTF-8 and Rust's +`#[export_name]` accepts dots. The numeric method and type identifiers derived +from these names are allocated by the ABI work, not here. + +## Bounded fields + +Every string, byte array and list declares a maximum, in stored fields and in +entry parameters and returns alike; a missing maximum is `UnboundedField`, +never a default. Integer fields carry their Rust width (`u8` to `u64`, `i8` to +`i64`) and optional narrower bounds; bounds outside the width or inverted are +`IntegerBoundsOutsideType`. Bounds are stored as `i128` so that a `u64` bound +above `i64::MAX` is representable and rejected as +`IntegerBoundNotNativelyRepresentable` (the native schema reads bounds as +signed 64-bit) instead of being truncated. Positions are contiguous from 0 at +every nesting level and are never renumbered. + +The width is part of the manifest. The build crate emits the width's own +range as the native `minimum` and `maximum` when the author gives no bounds, +so native sized-integer inference reproduces the declared width; an author +bound narrower than the width yields the narrower native storage type, which +generated codecs accept. + +## What the validator checks, and what it leaves to native + +`dash_sdk_contract::validate` runs every check and returns every diagnostic; +there is no first-error return. Diagnostics are typed, append-only and carry +stable codes (`DSC0001` onwards, provisional). + +The validator owns what the native host cannot know: + +- grammar (unknown attributes, options and values; missing, duplicate and + mutually exclusive options; sugar conflicts); +- identity (duplicate collections, indexes, methods, export symbols, rules, + modules, interfaces, properties and positions; non-contiguous positions; + invalid names); +- conflicts between an attribute and a builder describing one item; +- boundedness of every variable-length field and wire value; +- cross-references (an index, sum, contested field match, ranked level, time + range source, terminal, reference agreement, receiver, rule or guard naming + something the declaration does not have); +- collection kinds (a singleton has no indexes and no document id field; a + persistent struct needs one); +- entry semantics (a `&mut self` entry on a collection whose documents cannot + be replaced has nothing to stage; a read-only entry cannot take `&mut + self`); +- the module graph (unknown providers and interfaces, self-imports, cycles, + an entry with no module when several exist); +- rule scope and kind; +- the capability catalogue (the private store is `CapabilityInterfaceDisabled`; + derived capabilities cannot be required explicitly). + +It deliberately does not mirror native numeric limits (ten indexes per type, +63-character indexed strings, one hundred properties, time-range caps) or +native schema dependencies (a range count needs a count, a ranking needs its +range axis, a prefix ranking excludes sum axes, a time range needs a system +timestamp). Those are enforced once, by Dash Platform Protocol, and the build +crate surfaces them by running the real contract validation. Two definitions +of "valid" would drift the day one of them changed. The exception is the +identity grammar: collection, property, property path and index name limits +intentionally match the native rules because on-chain identity must agree; +see the identity table above. + +## Persistence + +`dash_sdk_contract::persistence` states the persistence semantics as enums so +generated wrappers and this chapter cannot drift: + +| | Result | +|---|---| +| Construct or mutate a detached value | nothing is written | +| `documents::().insert(value)` | stages a native create | +| `documents::().edit(id, closure)` | loads one record and stages the update when the closure returns successfully | +| An exported `&mut self` entry returns successfully | its wrapper loads exactly the addressed record and stages the receiver update | +| `Drop`, an escaping reference, an unmarked helper | nothing is written | + +Document ids, revisions, owners and storage flags are host managed. A receiver +entry on a document collection takes the target document id as its first wire +argument; a singleton receiver takes none, its key being reserved. + +## Indexes and the native catalogue + +`IndexSpec` expresses every index feature the native catalogue supports: +unique, null searchability, contested parameters (field matches, masternode +vote resolution, description), count and offset count, range count, sum and +range sum, average sugar, count ranking at the terminal level or at named +prefix levels, sum and average ranking, time-range buckets with an optional +time to live, and the index-only +options terminal, preallocated and skip-if-absent. Collection-level count, +sum, average and index-only flags, the document type switches, the bounded key +requirements and per-action token costs are on `CollectionSpec`. Every +reference target (identity, contract, token, permanent document with property +agreement, identity public key) is a `FieldType::Reference`. + +System timestamps and block height stamps (`$createdAt`, `$updatedAt`, +`$transferredAt` and their height variants) exist on a document only when the +collection requires them, so `requires = ["$createdAt"]` on the collection is +what makes them present; a time-range index on a system timestamp needs the +collection to require it (`TimeRangeSourceNotRequired` otherwise), which is +the native rule. User fields are required on the field itself. + +Average sugar (`average = "p"`, `range_average`) is expanded into `count` +plus `sum = "p"` and `range_count` plus `range_sum` before the manifest, +with the same conflict rules the native parser applies to `averageable`: an +omitted option is promoted, an explicit `count = false`, `range_count = +false` or `range_sum = false` next to the sugar is a `ConflictingOption`. +The manifest has no average fields. + +Index definitions on existing document types are frozen by the existing +versioned native update validation: adding, removing or changing an index on +an existing type is rejected, and a new type may declare indexes through +normal validation. The SDK adds no migration or backfill. + +## Rules and native awards + +A rule guards ordinary actions of one collection with either a native bounded +guard expression (`GuardExpr`, the author-facing form of the proposed guard +node set: literals, old, new and context field reads, existence and null +tests, comparisons, checked arithmetic, boolean operators and a conditional) +or a read-only WASM predicate exported by one of the contract's modules. The +rule's identity is its collection and name; its action set is stored sorted. + +The contested award is unrepresentable. `ActionScope` has no award variant, a +contested index is declared only through its supported parameters, and a +contested unique index may sit next to rules on ordinary actions of the same +collection; those rules apply to ordinary writes and never to the award. + +## Capabilities + +The manifest's capability table lists everything the contract needs, explicit +(`requires = ["acl", "randomness"]`) or derived (`write = "contract"`, native +guards, WASM predicates, typed collections, the private store, stored +receipts, entries, several modules), each with its catalogue status: + +| Status | Meaning | +|---|---| +| `Native` | supported by the native host today | +| `PendingNative` | declarable and specified, no native implementation yet; the build crate reports it as a gap and refuses to call the manifest deployable | +| `InterfaceDisabled` | catalogued, rejected by the validator until specified (the private store) | + +Typed specialized collections (`TypedCollectionSpec`: sum, big sum, count, +count-and-sum, provable sum, provable count, ranked, append and commitment +families) are a manifest slot with a key type, an element type and an optional +element bound. Their operation sets, limits, privacy model and native adapters +are separate capability work; there is no raw path, raw element or database +handle anywhere in the model. + +## Named modules + +A package may build several WASM module targets. `ModuleSpec` names each and +lists the interfaces it imports; `InterfaceSpec` names a provider module and +its functions. The manifest records the sorted modules and interfaces and the +`(importer, provider, interface)` bindings, and rejects cycles and +self-imports. A package that declares no module has one implicit module named +`main`. An entry binds to one module; moving it between modules changes the +binding and nothing else, which a test pins by comparing the method table +before and after a move. + +## The canonical manifest + +`CanonicalManifest` is sorted throughout: modules and interfaces by name, +bindings by tuple, collections by name with fields by position and indexes by +name, typed collections by id, methods by name, rules by collection and name, +capabilities by requirement. Every table is keyed by a required unique +identity, so two declarations that differ only in source order, in attribute +versus builder origin, or in which module hosts an entry (beyond the binding +itself) produce equal manifests. The manifest is built only by the validator +and has no wire encoding or digest here. + +## What is provisional + +- The attribute spellings and option names. +- The export symbol scheme `dash_entry_`. +- The method, module, interface and rule name grammars and the implicit + module name `main`. +- The meaning of `schema = N` (an author-declared schema revision recorded for + compatibility reports). +- The diagnostic codes. +- The `GuardExpr` node set, which mirrors the proposed native guard grammar; + the canonical guard AST is specified by the guards work. +- The home of the manifest shape in this crate, until the ABI work allocates + its encoding. diff --git a/packages/check-features/src/main.rs b/packages/check-features/src/main.rs index 8cba6485410..ba94c7bbafa 100644 --- a/packages/check-features/src/main.rs +++ b/packages/check-features/src/main.rs @@ -11,6 +11,7 @@ fn main() { ("rs-drive-proof-verifier", vec![]), ("rs-platform-wallet", vec![]), ("dash-platform-queries", vec![]), + ("rs-dash-sdk-contract", vec![]), ]; for (specific_crate, to_ignore) in crates { diff --git a/packages/rs-dash-sdk-contract/Cargo.toml b/packages/rs-dash-sdk-contract/Cargo.toml new file mode 100644 index 00000000000..2152c9fdee5 --- /dev/null +++ b/packages/rs-dash-sdk-contract/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "dash-sdk-contract" +version.workspace = true +edition = "2021" +rust-version.workspace = true +authors = ["Dash Core Team"] +license = "MIT" +description = "Contract-author SDK for DashVM: declaration model, attribute grammar, diagnostics and canonical manifest" + +[dependencies] +thiserror = { version = "2.0.17", default-features = false } + +[features] +default = ["std"] +std = ["thiserror/std"] diff --git a/packages/rs-dash-sdk-contract/README.md b/packages/rs-dash-sdk-contract/README.md new file mode 100644 index 00000000000..d9f105fedf9 --- /dev/null +++ b/packages/rs-dash-sdk-contract/README.md @@ -0,0 +1,24 @@ +# dash-sdk-contract + +Contract-author SDK for DashVM: the declaration model behind `#[persistent]`, +`#[index]`, `#[rule]` and `#[entry]`, the attribute grammar those macros +implement, the diagnostics they report, and the canonical manifest a contract +package publishes. + +```rust +use dash_sdk_contract::prelude::*; +``` + +This crate specifies the author-facing model. It carries no proc macros, no +host context and no runtime. It compiles without `std` on `wasm32v1-none`: + +```bash +cargo check -p dash-sdk-contract --no-default-features --target wasm32v1-none +cargo test -p dash-sdk-contract +cargo test -p dash-sdk-contract --no-default-features +``` + +The chapter [Contract Declarations and the Author API](../../book/src/dashvm/contract-declarations.md) +in the Dash Platform Book describes the grammar, the identity rules, the +persistence semantics and what the validator checks versus what native +validation enforces. diff --git a/packages/rs-dash-sdk-contract/src/declare/capability.rs b/packages/rs-dash-sdk-contract/src/declare/capability.rs new file mode 100644 index 00000000000..6dca26dae17 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/declare/capability.rs @@ -0,0 +1,136 @@ +//! The capability catalogue: what a contract may require, and whether the +//! native host supports it today. + +use core::fmt; + +use super::collections::TypedCollectionKind; + +/// Support status of a capability in the native host. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum CapabilityStatus { + /// Supported by the native host today; a manifest requesting it can be + /// deployed. + Native, + /// Specified, declarable, but without a native implementation yet. The + /// validator accepts the declaration; the build crate reports it as a + /// native gap and refuses to call the manifest deployable. + PendingNative, + /// Catalogued but disabled: the declaration is rejected until the + /// capability's semantics are specified. Today only the private document + /// store, whose encryption, key control, query visibility and proof + /// behaviour are not yet specified. + InterfaceDisabled, +} + +/// A capability a contract requires, either explicitly (`requires = [...]`) +/// or derived from its declarations. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum CapabilityRequirement { + /// The native access-control primitive. Explicit. + Acl, + /// The native randomness primitive. Explicit. + Randomness, + /// `write = "contract"`: only the contract's own code may create + /// documents. Derived. + ContractWrites, + /// A rule with a native guard expression. Derived. + NativeGuards, + /// A rule with a read-only WASM predicate. Derived. + WasmPredicates, + /// A typed specialized collection of the given kind. Derived. + TypedCollections(TypedCollectionKind), + /// `store = "private"`. Derived. + PrivateStore, + /// Stored receipts (the default policy). Derived. + StoredReceipts, + /// At least one entry, which needs the DashVM runtime. Derived. + Entries, + /// More than one module, which needs bundle linking. Derived. + Modules, +} + +impl CapabilityRequirement { + /// Whether the author writes this requirement (as opposed to the validator + /// deriving it). + pub fn is_explicit(&self) -> bool { + matches!( + self, + CapabilityRequirement::Acl | CapabilityRequirement::Randomness + ) + } + + /// The catalogue status of the requirement. + pub fn status(&self) -> CapabilityStatus { + match self { + CapabilityRequirement::PrivateStore => CapabilityStatus::InterfaceDisabled, + CapabilityRequirement::Acl + | CapabilityRequirement::Randomness + | CapabilityRequirement::ContractWrites + | CapabilityRequirement::NativeGuards + | CapabilityRequirement::WasmPredicates + | CapabilityRequirement::TypedCollections(_) + | CapabilityRequirement::StoredReceipts + | CapabilityRequirement::Entries + | CapabilityRequirement::Modules => CapabilityStatus::PendingNative, + } + } +} + +impl fmt::Display for CapabilityRequirement { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CapabilityRequirement::Acl => f.write_str("acl"), + CapabilityRequirement::Randomness => f.write_str("randomness"), + CapabilityRequirement::ContractWrites => f.write_str("contract writes"), + CapabilityRequirement::NativeGuards => f.write_str("native guards"), + CapabilityRequirement::WasmPredicates => f.write_str("wasm predicates"), + CapabilityRequirement::TypedCollections(kind) => { + write!(f, "typed collections ({kind})") + } + CapabilityRequirement::PrivateStore => f.write_str("private store"), + CapabilityRequirement::StoredReceipts => f.write_str("stored receipts"), + CapabilityRequirement::Entries => f.write_str("entries"), + CapabilityRequirement::Modules => f.write_str("modules"), + } + } +} + +/// Whether the host stores a receipt for every outer invocation. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ReceiptPolicy { + /// One bounded receipt per outer invocation, the default. + #[default] + Stored, + /// No receipts. + Disabled, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_keep_the_private_store_catalogued_but_disabled() { + assert_eq!( + CapabilityRequirement::PrivateStore.status(), + CapabilityStatus::InterfaceDisabled + ); + assert!(!CapabilityRequirement::PrivateStore.is_explicit()); + } + + #[test] + fn should_mark_acl_and_randomness_explicit_and_pending() { + for capability in [ + CapabilityRequirement::Acl, + CapabilityRequirement::Randomness, + ] { + assert!(capability.is_explicit()); + assert_eq!(capability.status(), CapabilityStatus::PendingNative); + } + } + + #[test] + fn should_default_receipts_to_stored() { + assert_eq!(ReceiptPolicy::default(), ReceiptPolicy::Stored); + } +} diff --git a/packages/rs-dash-sdk-contract/src/declare/collection.rs b/packages/rs-dash-sdk-contract/src/declare/collection.rs new file mode 100644 index 00000000000..8668f1f4ab0 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/declare/collection.rs @@ -0,0 +1,477 @@ +//! Document collections and singletons: the native document type switches, +//! the stored fields and the indexes. + +use alloc::string::String; +use alloc::vec::Vec; + +use super::field::{FieldSpec, FieldType, ReferenceTarget}; +use super::index::IndexSpec; +use super::rule::ActionScope; +use super::DeclarationOrigin; +use crate::identity::{CollectionName, PropertyName, PropertyPath}; + +/// Whether a collection holds many documents or one reserved record. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum CollectionKind { + /// Ordinary documents addressed by document id. + Documents, + /// One native document under a reserved key: configuration, not a + /// separate storage engine. + Singleton, +} + +/// Who may create documents. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum WritePolicy { + /// Anyone (native creation restriction 0). + #[default] + Any, + /// The contract owner only (native creation restriction 1). + Owner, + /// The contract's own code only. Pending native support; derives the + /// `ContractWrites` capability requirement. + Contract, +} + +/// Marketplace trade mode. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TradeMode { + /// No marketplace. + #[default] + None, + /// Direct purchase at the listed price. + DirectPurchase, +} + +/// Signature security level required to write. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SecurityLevel { + /// Critical. + Critical, + /// High, the default. + #[default] + High, + /// Medium. + Medium, +} + +/// Identity bounded key requirement (native storage key requirements). +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum BoundedKeyRequirement { + /// One non-replaceable key. + Unique, + /// Several keys. + Multiple, + /// Several keys with a reference to the latest. + MultipleReferenceToLatest, +} + +/// Document store kind. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Store { + /// Ordinary documents, readable by anyone with a proof. + #[default] + Public, + /// Private document store. Catalogued, interface disabled: private Rust + /// fields and access control are not encryption, and the store's + /// encryption, key control, query visibility and proof behaviour are + /// specified separately before this can be enabled. + Private, +} + +/// What happens to the tokens a priced action charges. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TokenCostEffect { + /// Transferred to the contract owner, the default. + #[default] + TransferToContractOwner, + /// Burned. + Burn, +} + +/// Who pays the gas of a priced action. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum GasPaidBy { + /// The document owner, the default. + #[default] + DocumentOwner, + /// The contract owner. + ContractOwner, + /// The contract owner when it can pay, else the document owner. + PreferContractOwner, +} + +/// A token price on one document action. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct TokenCost { + /// The token contract; the declaring contract when absent. + pub contract: Option<[u8; 32]>, + /// Token position in the token contract. + pub token_position: u16, + /// Amount charged. + pub amount: u64, + /// What happens to the tokens. + pub effect: TokenCostEffect, + /// Who pays gas. + pub gas_paid_by: GasPaidBy, +} + +impl TokenCost { + /// A cost in the declaring contract's token with default effect and payer. + pub fn new(token_position: u16, amount: u64) -> Self { + TokenCost { + contract: None, + token_position, + amount, + effect: TokenCostEffect::default(), + gas_paid_by: GasPaidBy::default(), + } + } + + /// Prices in another contract's token. + pub fn contract(mut self, contract: [u8; 32]) -> Self { + self.contract = Some(contract); + self + } + + /// Sets the effect. + pub fn effect(mut self, effect: TokenCostEffect) -> Self { + self.effect = effect; + self + } + + /// Sets the gas payer. + pub fn gas_paid_by(mut self, gas_paid_by: GasPaidBy) -> Self { + self.gas_paid_by = gas_paid_by; + self + } +} + +/// A token cost bound to an action. One per action. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct TokenCostSpec { + /// The priced action. + pub action: ActionScope, + /// The price. + pub cost: TokenCost, +} + +/// A document collection or singleton. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CollectionSpec { + /// Where the spec came from. + pub origin: DeclarationOrigin, + /// The collection's identity and native document type name. + pub name: CollectionName, + /// Documents or singleton. + pub kind: CollectionKind, + /// Author-declared schema revision, at least 1. Recorded for compatibility + /// reports; provisional meaning. + pub schema_revision: u32, + /// Who may create documents. + pub write: WritePolicy, + /// Documents may be replaced. + pub mutable: bool, + /// Documents may be deleted. + pub deletable: bool, + /// Every revision is kept. + pub keep_history: bool, + /// Transfer revisions are kept. + pub keep_transfer_history: bool, + /// Purchase revisions are kept. + pub keep_purchase_history: bool, + /// Price-update revisions are kept. + pub keep_pricing_history: bool, + /// Documents may be transferred. + pub transferable: bool, + /// Marketplace mode. + pub trade: TradeMode, + /// Signature security level required to write. + pub security_level: SecurityLevel, + /// Identity encryption bounded key requirement. + pub encryption_key: Option, + /// Identity decryption bounded key requirement. + pub decryption_key: Option, + /// Count tree on the primary key. `None` when the author said nothing, so + /// that average sugar may promote it; an explicit `Some(false)` next to + /// `average` is a conflict, as it is natively. + pub count: Option, + /// Provable count on the primary key; explicitness as for `count`. + pub range_count: Option, + /// Integer property summed on the primary key. + pub sum: Option, + /// Provable sum on the primary key; explicitness as for `count`. + pub range_sum: Option, + /// Sugar for `count` plus `sum`; expanded by the validator, never stored in + /// the manifest. + pub average: Option, + /// Sugar for `range_count` plus `range_sum`; expanded by the validator. + pub range_average: bool, + /// Documents live only in their indexes. + pub index_only: bool, + /// System properties every document must carry (`$createdAt`, + /// `$updatedAt`, `$transferredAt` and the block height stamps). A + /// system timestamp is populated only when required, so a time-range + /// index on one needs it listed here. User fields are required through + /// [`FieldSpec::required`]. + pub requires: Vec, + /// Token prices per action. + pub token_costs: Vec, + /// Document store. + pub store: Store, + /// The Rust field marked `#[document_id]`; required on document + /// collections, forbidden on singletons. A Rust name, never part of the + /// manifest. + pub document_id_field: Option, + /// Stored fields. + pub fields: Vec, + /// Indexes. + pub indexes: Vec, +} + +impl CollectionSpec { + fn with_kind(name: CollectionName, kind: CollectionKind) -> Self { + CollectionSpec { + origin: DeclarationOrigin::Builder, + name, + kind, + schema_revision: 1, + write: WritePolicy::default(), + mutable: true, + deletable: true, + keep_history: false, + keep_transfer_history: false, + keep_purchase_history: false, + keep_pricing_history: false, + transferable: false, + trade: TradeMode::default(), + security_level: SecurityLevel::default(), + encryption_key: None, + decryption_key: None, + count: None, + range_count: None, + sum: None, + range_sum: None, + average: None, + range_average: false, + index_only: false, + requires: Vec::new(), + token_costs: Vec::new(), + store: Store::default(), + document_id_field: None, + fields: Vec::new(), + indexes: Vec::new(), + } + } + + /// A document collection with default switches. + pub fn documents(name: CollectionName) -> Self { + Self::with_kind(name, CollectionKind::Documents) + } + + /// A singleton with default switches. + pub fn singleton(name: CollectionName) -> Self { + Self::with_kind(name, CollectionKind::Singleton) + } + + /// Records the origin. + pub fn with_origin(mut self, origin: DeclarationOrigin) -> Self { + self.origin = origin; + self + } + + /// Sets the schema revision. + pub fn schema_revision(mut self, revision: u32) -> Self { + self.schema_revision = revision; + self + } + + /// Sets who may create documents. + pub fn write(mut self, write: WritePolicy) -> Self { + self.write = write; + self + } + + /// Sets whether documents may be replaced. + pub fn mutable(mut self, mutable: bool) -> Self { + self.mutable = mutable; + self + } + + /// Sets whether documents may be deleted. + pub fn deletable(mut self, deletable: bool) -> Self { + self.deletable = deletable; + self + } + + /// Keeps every revision. + pub fn keep_history(mut self, keep: bool) -> Self { + self.keep_history = keep; + self + } + + /// Keeps transfer revisions. + pub fn keep_transfer_history(mut self, keep: bool) -> Self { + self.keep_transfer_history = keep; + self + } + + /// Keeps purchase revisions. + pub fn keep_purchase_history(mut self, keep: bool) -> Self { + self.keep_purchase_history = keep; + self + } + + /// Keeps price-update revisions. + pub fn keep_pricing_history(mut self, keep: bool) -> Self { + self.keep_pricing_history = keep; + self + } + + /// Sets whether documents may be transferred. + pub fn transferable(mut self, transferable: bool) -> Self { + self.transferable = transferable; + self + } + + /// Sets the marketplace mode. + pub fn trade(mut self, trade: TradeMode) -> Self { + self.trade = trade; + self + } + + /// Sets the signature security level. + pub fn security_level(mut self, level: SecurityLevel) -> Self { + self.security_level = level; + self + } + + /// Sets the encryption key requirement. + pub fn encryption_key(mut self, requirement: BoundedKeyRequirement) -> Self { + self.encryption_key = Some(requirement); + self + } + + /// Sets the decryption key requirement. + pub fn decryption_key(mut self, requirement: BoundedKeyRequirement) -> Self { + self.decryption_key = Some(requirement); + self + } + + /// Counts documents on the primary key. + pub fn count(mut self, count: bool) -> Self { + self.count = Some(count); + self + } + + /// Provable counts on the primary key. + pub fn range_count(mut self, range_count: bool) -> Self { + self.range_count = Some(range_count); + self + } + + /// Sums a property on the primary key. + pub fn sum(mut self, property: PropertyName) -> Self { + self.sum = Some(property); + self + } + + /// Provable sums on the primary key. + pub fn range_sum(mut self, range_sum: bool) -> Self { + self.range_sum = Some(range_sum); + self + } + + /// Average sugar: `count` plus `sum` on the property. + pub fn average(mut self, property: PropertyName) -> Self { + self.average = Some(property); + self + } + + /// Range average sugar: `range_count` plus `range_sum`. + pub fn range_average(mut self, range_average: bool) -> Self { + self.range_average = range_average; + self + } + + /// Stores documents only in their indexes. + pub fn index_only(mut self, index_only: bool) -> Self { + self.index_only = index_only; + self + } + + /// Sets the document store. + pub fn store(mut self, store: Store) -> Self { + self.store = store; + self + } + + /// Names the Rust field carrying the document id. + pub fn document_id_field(mut self, field: impl Into) -> Self { + self.document_id_field = Some(field.into()); + self + } + + /// Adds a stored field. + pub fn field(mut self, field: FieldSpec) -> Self { + self.fields.push(field); + self + } + + /// Adds an index. + pub fn index(mut self, index: IndexSpec) -> Self { + self.indexes.push(index); + self + } + + /// Requires a system property on every document. + pub fn requires(mut self, property: PropertyPath) -> Self { + self.requires.push(property); + self + } + + /// Prices an action. + pub fn token_cost(mut self, action: ActionScope, cost: TokenCost) -> Self { + self.token_costs.push(TokenCostSpec { action, cost }); + self + } + + /// Whether two specs describe the same collection apart from origin, + /// indexes and the order of order-insensitive members (fields at every + /// nesting level, token costs, reference agreements). Used to tell a + /// restatement or extension from a conflict. + pub fn same_shape_ignoring_indexes(&self, other: &CollectionSpec) -> bool { + let mut left = self.normalized(); + let mut right = other.normalized(); + left.origin = right.origin; + left.indexes = Vec::new(); + right.indexes = Vec::new(); + left == right + } + + /// A copy with every order-insensitive member in canonical order: fields + /// by position at every nesting level, token costs by action, reference + /// agreements by referring property, enum values and required system + /// properties sorted. + pub fn normalized(&self) -> CollectionSpec { + let mut normalized = self.clone(); + normalize_fields(&mut normalized.fields); + normalized.token_costs.sort_by_key(|cost| cost.action); + normalized.requires.sort(); + normalized + } +} + +fn normalize_fields(fields: &mut [FieldSpec]) { + for field in fields.iter_mut() { + match &mut field.ty { + FieldType::Object(nested) => normalize_fields(nested), + FieldType::Reference(ReferenceTarget::PermanentDocument { agreement, .. }) => { + agreement.sort(); + } + FieldType::Enum(values) => values.sort(), + _ => {} + } + } + fields.sort_by(|a, b| a.position.cmp(&b.position).then(a.name.cmp(&b.name))); +} diff --git a/packages/rs-dash-sdk-contract/src/declare/collections.rs b/packages/rs-dash-sdk-contract/src/declare/collections.rs new file mode 100644 index 00000000000..686beb280e1 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/declare/collections.rs @@ -0,0 +1,118 @@ +//! Typed specialized collections: the manifest slot for native tree families +//! that ordinary document schemas do not expose. +//! +//! Only the declaration shape is specified here. Operation sets, limits, the +//! privacy model and the native adapters are separate capability work; every +//! kind is [`CapabilityStatus::PendingNative`](super::CapabilityStatus). +//! There is no raw path, raw element or database handle anywhere in this +//! model: a typed collection is a declared capability with a key and element +//! type, not an escape hatch. + +use core::fmt; + +use super::entry::ValueType; +use super::DeclarationOrigin; +use crate::identity::CollectionName; + +/// The native tree family behind a typed collection. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TypedCollectionKind { + /// Sum tree. + Sum, + /// Big sum tree (128-bit totals). + BigSum, + /// Count tree. + Count, + /// Count and sum tree. + CountSum, + /// Provable sum tree (range sums with proofs). + ProvableSum, + /// Provable count tree (range counts with proofs). + ProvableCount, + /// Ranked tree. + Ranked, + /// Append-only (Merkle mountain range) collection. + Append, + /// Commitment collection. + Commitment, +} + +impl TypedCollectionKind { + /// Every kind in the catalogue. + pub const ALL: &'static [TypedCollectionKind] = &[ + TypedCollectionKind::Sum, + TypedCollectionKind::BigSum, + TypedCollectionKind::Count, + TypedCollectionKind::CountSum, + TypedCollectionKind::ProvableSum, + TypedCollectionKind::ProvableCount, + TypedCollectionKind::Ranked, + TypedCollectionKind::Append, + TypedCollectionKind::Commitment, + ]; +} + +impl fmt::Display for TypedCollectionKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + TypedCollectionKind::Sum => "sum", + TypedCollectionKind::BigSum => "big sum", + TypedCollectionKind::Count => "count", + TypedCollectionKind::CountSum => "count and sum", + TypedCollectionKind::ProvableSum => "provable sum", + TypedCollectionKind::ProvableCount => "provable count", + TypedCollectionKind::Ranked => "ranked", + TypedCollectionKind::Append => "append", + TypedCollectionKind::Commitment => "commitment", + }) + } +} + +/// A typed specialized collection declaration. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TypedCollectionSpec { + /// Where the spec came from. + pub origin: DeclarationOrigin, + /// The collection's identity, in the same namespace as document + /// collections. + pub id: CollectionName, + /// The tree family. + pub kind: TypedCollectionKind, + /// The key type. + pub key: ValueType, + /// The element type. + pub element: ValueType, + /// Upper bound on the number of elements, when declared. + pub max_elements: Option, +} + +impl TypedCollectionSpec { + /// A builder-declared typed collection. + pub fn new( + id: CollectionName, + kind: TypedCollectionKind, + key: ValueType, + element: ValueType, + ) -> Self { + TypedCollectionSpec { + origin: DeclarationOrigin::Builder, + id, + kind, + key, + element, + max_elements: None, + } + } + + /// Records the origin. + pub fn with_origin(mut self, origin: DeclarationOrigin) -> Self { + self.origin = origin; + self + } + + /// Bounds the element count. + pub fn max_elements(mut self, max_elements: u64) -> Self { + self.max_elements = Some(max_elements); + self + } +} diff --git a/packages/rs-dash-sdk-contract/src/declare/entry.rs b/packages/rs-dash-sdk-contract/src/declare/entry.rs new file mode 100644 index 00000000000..6c31d49647c --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/declare/entry.rs @@ -0,0 +1,195 @@ +//! Entries: the externally callable functions of a contract and their wire +//! types. +//! +//! Only a function marked `#[entry]` is callable; Rust `pub` alone exports +//! nothing. An entry's identity is its [`MethodName`], unique across every +//! module of the contract. Its WASM export symbol is derived from that name +//! (see [`crate::identity::entry_export_symbol`]) and its module is only a +//! binding: moving the function between modules changes the binding and +//! nothing else. + +use alloc::boxed::Box; +use alloc::string::String; +use alloc::vec::Vec; + +use super::field::IntegerWidth; +use super::DeclarationOrigin; +use crate::identity::{CollectionName, MethodName, ModuleName}; + +/// The receiver of an entry. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum Receiver { + /// A free function. + None, + /// `&self` on a persistent struct: the wrapper loads the addressed record + /// and stages nothing. + Ref(CollectionName), + /// `&mut self` on a persistent struct: the wrapper loads the addressed + /// record and stages the receiver update when the method returns + /// successfully. The only implicit staging point in the model. + Mut(CollectionName), +} + +impl Receiver { + /// The receiver's collection, if any. + pub fn collection(&self) -> Option<&CollectionName> { + match self { + Receiver::None => None, + Receiver::Ref(collection) | Receiver::Mut(collection) => Some(collection), + } + } + + /// Whether the receiver is mutable. + pub fn is_mutable(&self) -> bool { + matches!(self, Receiver::Mut(_)) + } +} + +/// A bounded wire value type for entry parameters and returns. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum ValueType { + /// `()` + Unit, + /// `bool` + Bool, + /// A Rust integer. + Integer(IntegerWidth), + /// `f64` + F64, + /// A string with a required character bound. + String { + /// Maximum characters; absent is an `UnboundedField` diagnostic. + max_chars: Option, + }, + /// A byte array with a required length bound. + Bytes { + /// Maximum bytes; absent is an `UnboundedField` diagnostic. + max_len: Option, + }, + /// A 32-byte identifier. + Identifier, + /// A document id. + DocumentId, + /// An optional value. + Option(Box), + /// A list with a required length bound. + List { + /// Maximum items; absent is an `UnboundedField` diagnostic. + max_len: Option, + /// The item type. + item: Box, + }, + /// An embedded bounded value struct. + Struct(Vec<(String, ValueType)>), +} + +impl ValueType { + /// A string of at most `max_chars` characters. + pub fn string(max_chars: u16) -> Self { + ValueType::String { + max_chars: Some(max_chars), + } + } + + /// A byte array of at most `max_len` bytes. + pub fn bytes(max_len: u16) -> Self { + ValueType::Bytes { + max_len: Some(max_len), + } + } + + /// A list of at most `max_len` items. + pub fn list(max_len: u32, item: ValueType) -> Self { + ValueType::List { + max_len: Some(max_len), + item: Box::new(item), + } + } + + /// An optional value. + pub fn option(inner: ValueType) -> Self { + ValueType::Option(Box::new(inner)) + } +} + +/// One entry parameter. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ParamSpec { + /// The parameter name. + pub name: String, + /// The wire type. + pub ty: ValueType, +} + +/// An entry declaration. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EntrySpec { + /// Where the spec came from. + pub origin: DeclarationOrigin, + /// The entry's identity. + pub name: MethodName, + /// The hosting module; the single module when absent. + pub module: Option, + /// The receiver. + pub receiver: Receiver, + /// The entry stages no writes. + pub read_only: bool, + /// Wire parameters after the receiver's document id, if any. + pub params: Vec, + /// Wire return type. + pub returns: ValueType, +} + +impl EntrySpec { + /// A free entry returning unit. + pub fn new(name: MethodName) -> Self { + EntrySpec { + origin: DeclarationOrigin::Builder, + name, + module: None, + receiver: Receiver::None, + read_only: false, + params: Vec::new(), + returns: ValueType::Unit, + } + } + + /// Records the origin. + pub fn with_origin(mut self, origin: DeclarationOrigin) -> Self { + self.origin = origin; + self + } + + /// Binds the entry to a module. + pub fn module(mut self, module: ModuleName) -> Self { + self.module = Some(module); + self + } + + /// Sets the receiver. + pub fn receiver(mut self, receiver: Receiver) -> Self { + self.receiver = receiver; + self + } + + /// Marks the entry read-only. + pub fn read_only(mut self, read_only: bool) -> Self { + self.read_only = read_only; + self + } + + /// Adds a parameter. + pub fn param(mut self, name: impl Into, ty: ValueType) -> Self { + self.params.push(ParamSpec { + name: name.into(), + ty, + }); + self + } + + /// Sets the return type. + pub fn returns(mut self, returns: ValueType) -> Self { + self.returns = returns; + self + } +} diff --git a/packages/rs-dash-sdk-contract/src/declare/field.rs b/packages/rs-dash-sdk-contract/src/declare/field.rs new file mode 100644 index 00000000000..5d61fd87340 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/declare/field.rs @@ -0,0 +1,291 @@ +//! Stored fields: their stable name and position, their type and their bounds. +//! +//! Every variable-length field declares an upper bound; integer bounds must +//! fit the declared Rust width. The width itself is part of the manifest and +//! is enforced natively by emitting the corresponding schema bounds, so a +//! `u32` field stays a `u32` after native inference. + +use alloc::string::String; +use alloc::vec::Vec; + +use crate::identity::{CollectionName, PropertyName, PropertyPath}; + +/// The Rust integer width of an integer field. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum IntegerWidth { + /// `u8` + U8, + /// `u16` + U16, + /// `u32` + U32, + /// `u64` + U64, + /// `i8` + I8, + /// `i16` + I16, + /// `i32` + I32, + /// `i64` + I64, +} + +impl IntegerWidth { + /// Every width. + pub const ALL: &'static [IntegerWidth] = &[ + IntegerWidth::U8, + IntegerWidth::U16, + IntegerWidth::U32, + IntegerWidth::U64, + IntegerWidth::I8, + IntegerWidth::I16, + IntegerWidth::I32, + IntegerWidth::I64, + ]; + + /// The smallest value of the width. + pub fn min_value(&self) -> i128 { + match self { + IntegerWidth::U8 | IntegerWidth::U16 | IntegerWidth::U32 | IntegerWidth::U64 => 0, + IntegerWidth::I8 => i8::MIN as i128, + IntegerWidth::I16 => i16::MIN as i128, + IntegerWidth::I32 => i32::MIN as i128, + IntegerWidth::I64 => i64::MIN as i128, + } + } + + /// The largest value of the width. + pub fn max_value(&self) -> i128 { + match self { + IntegerWidth::U8 => u8::MAX as i128, + IntegerWidth::U16 => u16::MAX as i128, + IntegerWidth::U32 => u32::MAX as i128, + IntegerWidth::U64 => u64::MAX as i128, + IntegerWidth::I8 => i8::MAX as i128, + IntegerWidth::I16 => i16::MAX as i128, + IntegerWidth::I32 => i32::MAX as i128, + IntegerWidth::I64 => i64::MAX as i128, + } + } + + /// Whether the width is signed. + pub fn is_signed(&self) -> bool { + matches!( + self, + IntegerWidth::I8 | IntegerWidth::I16 | IntegerWidth::I32 | IntegerWidth::I64 + ) + } + + /// The Rust type name. + pub fn rust_name(&self) -> &'static str { + match self { + IntegerWidth::U8 => "u8", + IntegerWidth::U16 => "u16", + IntegerWidth::U32 => "u32", + IntegerWidth::U64 => "u64", + IntegerWidth::I8 => "i8", + IntegerWidth::I16 => "i16", + IntegerWidth::I32 => "i32", + IntegerWidth::I64 => "i64", + } + } +} + +/// Author-declared integer bounds. `i128` so that a bound outside `i64` is +/// representable and can be rejected instead of truncated. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct IntegerBounds { + /// Inclusive lower bound. + pub min: Option, + /// Inclusive upper bound. + pub max: Option, +} + +impl IntegerBounds { + /// No bounds beyond the width's own range. + pub const NONE: IntegerBounds = IntegerBounds { + min: None, + max: None, + }; + + /// Both bounds. + pub fn new(min: i128, max: i128) -> Self { + IntegerBounds { + min: Some(min), + max: Some(max), + } + } +} + +/// What an identifier field refers to. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum ReferenceTarget { + /// An identity. + Identity, + /// A data contract. + Contract, + /// A token. + Token, + /// A document of a type that forbids deletion. + PermanentDocument { + /// The contract holding the referenced type; the declaring contract + /// when absent. + contract: Option<[u8; 32]>, + /// The referenced document type. + document_type: CollectionName, + /// Referring property to referenced property equalities enforced at + /// write time. + agreement: Vec<(PropertyPath, PropertyPath)>, + }, + /// An identity public key; the reference carries the identity id and the + /// named sibling property carries the key id. + IdentityPublicKey { + /// The property of the same collection carrying the key id. + key_id_field: PropertyPath, + }, +} + +/// The type of a stored field. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum FieldType { + /// `bool` + Bool, + /// A Rust integer with optional narrower bounds. + Integer { + /// The Rust width. + width: IntegerWidth, + /// Author bounds inside the width. + bounds: IntegerBounds, + }, + /// `f64` + F64, + /// A string with a required character bound. + String { + /// Minimum characters. + min_chars: Option, + /// Maximum characters; absent is an `UnboundedField` diagnostic. + max_chars: Option, + }, + /// A byte array with a required length bound. + Bytes { + /// Minimum bytes. + min_len: Option, + /// Maximum bytes; absent is an `UnboundedField` diagnostic. + max_len: Option, + }, + /// A 32-byte identifier with no declared target. + Identifier, + /// A 32-byte identifier referring to a native object. + Reference(ReferenceTarget), + /// One of a closed set of strings. + Enum(Vec), + /// A nested object with its own positioned fields. + Object(Vec), +} + +impl FieldType { + /// An integer of the given width with no extra bounds. + pub fn integer(width: IntegerWidth) -> Self { + FieldType::Integer { + width, + bounds: IntegerBounds::NONE, + } + } + + /// An integer of the given width bounded to `min..=max`. + pub fn bounded_integer(width: IntegerWidth, min: i128, max: i128) -> Self { + FieldType::Integer { + width, + bounds: IntegerBounds::new(min, max), + } + } + + /// A string of at most `max_chars` characters. + pub fn string(max_chars: u16) -> Self { + FieldType::String { + min_chars: None, + max_chars: Some(max_chars), + } + } + + /// A byte array of at most `max_len` bytes. + pub fn bytes(max_len: u16) -> Self { + FieldType::Bytes { + min_len: None, + max_len: Some(max_len), + } + } + + /// An identifier referring to an identity. + pub fn identity() -> Self { + FieldType::Reference(ReferenceTarget::Identity) + } +} + +/// A stored field. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct FieldSpec { + /// The serialized property name; part of the field's identity. + pub name: PropertyName, + /// The serialization position; part of the field's identity. Positions are + /// contiguous from 0 at each nesting level and never renumbered. + pub position: u32, + /// The type and bounds. + pub ty: FieldType, + /// Whether the property is required, default true. + pub required: bool, + /// Whether the property is validated but not stored, default false. + pub transient: bool, + /// Human description. + pub description: Option, +} + +impl FieldSpec { + /// A required, stored field. + pub fn new(name: PropertyName, position: u32, ty: FieldType) -> Self { + FieldSpec { + name, + position, + ty, + required: true, + transient: false, + description: None, + } + } + + /// Makes the field optional. + pub fn optional(mut self) -> Self { + self.required = false; + self + } + + /// Makes the field transient. + pub fn transient(mut self) -> Self { + self.transient = true; + self + } + + /// Adds a description. + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_give_each_width_its_rust_range() { + assert_eq!(IntegerWidth::U8.min_value(), 0); + assert_eq!(IntegerWidth::U8.max_value(), 255); + assert_eq!(IntegerWidth::U64.max_value(), u64::MAX as i128); + assert_eq!(IntegerWidth::I8.min_value(), -128); + assert_eq!(IntegerWidth::I64.min_value(), i64::MIN as i128); + assert_eq!(IntegerWidth::I64.max_value(), i64::MAX as i128); + assert!(IntegerWidth::I16.is_signed()); + assert!(!IntegerWidth::U16.is_signed()); + } +} diff --git a/packages/rs-dash-sdk-contract/src/declare/index.rs b/packages/rs-dash-sdk-contract/src/declare/index.rs new file mode 100644 index 00000000000..69c11b0d852 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/declare/index.rs @@ -0,0 +1,301 @@ +//! Index declarations covering the full native index catalogue. +//! +//! Combinations that native rejects (a range count without a count, a ranking +//! without the matching range axis, a prefix ranking with a sum axis, a time +//! range on a user property) are not re-validated here: native validation is +//! the authority and the build crate surfaces its errors. + +use alloc::string::String; +use alloc::vec::Vec; + +use super::DeclarationOrigin; +use crate::identity::{IndexName, PropertyName, PropertyPath}; + +/// Count fast path on an index. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Countability { + /// Plain tree, no count. + #[default] + NotCountable, + /// Count tree: totals in constant time. + Countable, + /// Provable count tree: totals plus offset and range queries. + CountableAllowingOffset, +} + +impl Countability { + /// Whether either count form is selected. + pub fn is_countable(&self) -> bool { + !matches!(self, Countability::NotCountable) + } +} + +/// Contested index resolution. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ContestedResolution { + /// Masternode vote, the only native resolution. + #[default] + MasternodeVote, +} + +/// Contested index parameters. The native award is an internal native action; +/// declaring these parameters is the only way a contract influences it. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ContestedSpec { + /// Index property to regular expression the value must match to be + /// contested. + pub field_matches: Vec<(PropertyPath, String)>, + /// How a contest is resolved. + pub resolution: ContestedResolution, + /// Human description. + pub description: Option, +} + +impl ContestedSpec { + /// A masternode-vote contest with the given field matches. + pub fn masternode_vote(field_matches: Vec<(PropertyPath, String)>) -> Self { + ContestedSpec { + field_matches, + resolution: ContestedResolution::MasternodeVote, + description: None, + } + } + + /// Adds a description. + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } +} + +/// Where count rankings are placed. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub enum RankedCount { + /// No count ranking. + #[default] + None, + /// At the terminal level. + Terminal, + /// At the named index property levels, which may include prefixes. + At(Vec), +} + +/// Ranking axes; each costs its own secondary tree. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct Ranking { + /// Count ranking. + pub count: RankedCount, + /// Sum ranking at the terminal level. + pub sum: bool, + /// Average ranking at the terminal level. + pub average: bool, +} + +/// Bucketing of the index's first property into time windows. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct TimeRangeSpec { + /// The bucketed property; must be the index's first property and a system + /// timestamp. + pub on: PropertyPath, + /// Window length in seconds. + pub range_secs: u64, + /// Interval between window starts in seconds. + pub step_secs: u64, + /// Grid offset in seconds. + pub phase_secs: u64, + /// Time to live in seconds: entries expire this long after their bucket + /// starts and expired windows are not queryable. `None` keeps entries + /// indefinitely. The native cap and the shared-grid rules are native + /// validation. + pub ttl_secs: Option, +} + +/// Index-only options: only meaningful when the collection is index-only. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct IndexOnlySpec { + /// The member key property; `$ownerId` when absent. + pub terminal: Option, + /// Preallocate the index path when the referenced document is created. + pub preallocated: bool, + /// Write no entry when the first property is absent. + pub skip_if_absent: bool, +} + +/// An index declaration. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IndexSpec { + /// Where the spec came from. + pub origin: DeclarationOrigin, + /// The index's identity within its collection. + pub name: IndexName, + /// Indexed property paths in order, all ascending. + pub properties: Vec, + /// Unique index. + pub unique: bool, + /// Null values are searchable, default true. + pub null_searchable: bool, + /// Contested parameters. + pub contested: Option, + /// Count fast path. `None` when the author said nothing, so that average + /// sugar may promote it; an explicit `Some(NotCountable)` next to + /// `average` is a conflict, as it is natively. + pub count: Option, + /// Range counts; explicitness as for `count`. + pub range_count: Option, + /// Integer property summed at the index. + pub sum: Option, + /// Range sums; explicitness as for `count`. + pub range_sum: Option, + /// Sugar for `count` plus `sum`; expanded by the validator. + pub average: Option, + /// Sugar for `range_count` plus `range_sum`; expanded by the validator. + pub range_average: bool, + /// Ranking axes. + pub ranked: Ranking, + /// Time range bucketing. + pub time_range: Option, + /// Index-only options. + pub index_only: Option, +} + +impl IndexSpec { + /// A plain ascending index over the given properties. + pub fn new(name: IndexName, properties: Vec) -> Self { + IndexSpec { + origin: DeclarationOrigin::Builder, + name, + properties, + unique: false, + null_searchable: true, + contested: None, + count: None, + range_count: None, + sum: None, + range_sum: None, + average: None, + range_average: false, + ranked: Ranking::default(), + time_range: None, + index_only: None, + } + } + + /// Records the origin. + pub fn with_origin(mut self, origin: DeclarationOrigin) -> Self { + self.origin = origin; + self + } + + /// Makes the index unique. + pub fn unique(mut self, unique: bool) -> Self { + self.unique = unique; + self + } + + /// Sets null searchability. + pub fn null_searchable(mut self, null_searchable: bool) -> Self { + self.null_searchable = null_searchable; + self + } + + /// Makes the index contested. + pub fn contested(mut self, contested: ContestedSpec) -> Self { + self.contested = Some(contested); + self + } + + /// Selects a count tree. + pub fn count(self) -> Self { + self.countability(Countability::Countable) + } + + /// Selects a provable count tree. + pub fn count_allowing_offset(self) -> Self { + self.countability(Countability::CountableAllowingOffset) + } + + /// Sets the count fast path explicitly, including `NotCountable`. + pub fn countability(mut self, count: Countability) -> Self { + self.count = Some(count); + self + } + + /// Enables range counts. + pub fn range_count(mut self, range_count: bool) -> Self { + self.range_count = Some(range_count); + self + } + + /// Sums a property at the index. + pub fn sum(mut self, property: PropertyName) -> Self { + self.sum = Some(property); + self + } + + /// Enables range sums. + pub fn range_sum(mut self, range_sum: bool) -> Self { + self.range_sum = Some(range_sum); + self + } + + /// Average sugar: `count` plus `sum` on the property. + pub fn average(mut self, property: PropertyName) -> Self { + self.average = Some(property); + self + } + + /// Range average sugar: `range_count` plus `range_sum`. + pub fn range_average(mut self, range_average: bool) -> Self { + self.range_average = range_average; + self + } + + /// Count ranking at the terminal level. + pub fn ranked_count(mut self) -> Self { + self.ranked.count = RankedCount::Terminal; + self + } + + /// Count ranking at the named levels. + pub fn ranked_count_at(mut self, levels: Vec) -> Self { + self.ranked.count = RankedCount::At(levels); + self + } + + /// Sum ranking. + pub fn ranked_sum(mut self, ranked_sum: bool) -> Self { + self.ranked.sum = ranked_sum; + self + } + + /// Average ranking. + pub fn ranked_average(mut self, ranked_average: bool) -> Self { + self.ranked.average = ranked_average; + self + } + + /// Buckets the first property into time windows. + pub fn time_range(mut self, time_range: TimeRangeSpec) -> Self { + self.time_range = Some(time_range); + self + } + + /// Sets index-only options. + pub fn index_only(mut self, options: IndexOnlySpec) -> Self { + self.index_only = Some(options); + self + } + + /// A copy with every order-insensitive member in canonical order: + /// contested field matches by property and ranked levels by property. + pub fn normalized(&self) -> IndexSpec { + let mut normalized = self.clone(); + if let Some(contested) = normalized.contested.as_mut() { + contested.field_matches.sort(); + } + if let RankedCount::At(levels) = &mut normalized.ranked.count { + levels.sort(); + } + normalized + } +} diff --git a/packages/rs-dash-sdk-contract/src/declare/mod.rs b/packages/rs-dash-sdk-contract/src/declare/mod.rs new file mode 100644 index 00000000000..a0901d73ba0 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/declare/mod.rs @@ -0,0 +1,145 @@ +//! The typed declaration model. +//! +//! A [`ContractDeclaration`] gathers everything a contract package declares: +//! WASM modules and the interfaces between them, document collections with +//! their fields and indexes, typed specialized collections, entries, rules, +//! explicitly required capabilities and the receipt policy. Attributes and +//! typed builders both produce these specs; each spec records its +//! [`DeclarationOrigin`] so the validator can name both sides of a conflict. +//! +//! Identity is by declared name (see [`crate::identity`]); the order in which +//! specs are added never matters, and a builder may restate an attribute +//! declaration verbatim or extend an attribute-declared collection with more +//! indexes, but may not change what the attribute said. + +pub mod capability; +pub mod collection; +pub mod collections; +pub mod entry; +pub mod field; +pub mod index; +pub mod module; +pub mod rule; + +use alloc::vec::Vec; + +pub use capability::{CapabilityRequirement, CapabilityStatus, ReceiptPolicy}; +pub use collection::{ + BoundedKeyRequirement, CollectionKind, CollectionSpec, GasPaidBy, SecurityLevel, Store, + TokenCost, TokenCostEffect, TokenCostSpec, TradeMode, WritePolicy, +}; +pub use collections::{TypedCollectionKind, TypedCollectionSpec}; +pub use entry::{EntrySpec, ParamSpec, Receiver, ValueType}; +pub use field::{FieldSpec, FieldType, IntegerBounds, IntegerWidth, ReferenceTarget}; +pub use index::{ + ContestedResolution, ContestedSpec, Countability, IndexOnlySpec, IndexSpec, RankedCount, + Ranking, TimeRangeSpec, +}; +pub use module::{ + implicit_module, InterfaceSpec, InternalFunctionSpec, ModuleSpec, IMPLICIT_MODULE, +}; +pub use rule::{ActionScope, FieldContext, GuardExpr, Literal, RuleKind, RuleSpec}; + +use crate::manifest::CanonicalManifest; +use crate::validate::{validate, Diagnostic}; + +/// Where a spec came from. Used to tell a harmless restatement from a +/// conflict and to name both sides in a `ConflictingDeclaration` diagnostic. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DeclarationOrigin { + /// Produced by an attribute on a Rust item. + Attribute, + /// Produced by a typed builder in a declaration module. + Builder, +} + +impl core::fmt::Display for DeclarationOrigin { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(match self { + DeclarationOrigin::Attribute => "attribute", + DeclarationOrigin::Builder => "builder", + }) + } +} + +/// Everything one contract package declares, before validation. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ContractDeclaration { + /// WASM module targets. Empty means one implicit module named `main`. + pub modules: Vec, + /// Interfaces modules provide to each other. + pub interfaces: Vec, + /// Document collections and singletons. + pub collections: Vec, + /// Typed specialized collections. + pub typed_collections: Vec, + /// Externally callable entries. + pub entries: Vec, + /// Rules on ordinary document actions. + pub rules: Vec, + /// Explicitly required capabilities (`requires = [...]`). + pub capabilities: Vec, + /// Receipt policy; stored unless disabled. + pub receipts: ReceiptPolicy, +} + +impl ContractDeclaration { + /// An empty declaration. + pub fn new() -> Self { + Self::default() + } + + /// Adds a module. + pub fn module(mut self, spec: ModuleSpec) -> Self { + self.modules.push(spec); + self + } + + /// Adds an interface. + pub fn interface(mut self, spec: InterfaceSpec) -> Self { + self.interfaces.push(spec); + self + } + + /// Adds a collection or singleton. + pub fn collection(mut self, spec: CollectionSpec) -> Self { + self.collections.push(spec); + self + } + + /// Adds a typed specialized collection. + pub fn typed_collection(mut self, spec: TypedCollectionSpec) -> Self { + self.typed_collections.push(spec); + self + } + + /// Adds an entry. + pub fn entry(mut self, spec: EntrySpec) -> Self { + self.entries.push(spec); + self + } + + /// Adds a rule. + pub fn rule(mut self, spec: RuleSpec) -> Self { + self.rules.push(spec); + self + } + + /// Declares an explicitly required capability. + pub fn require(mut self, capability: CapabilityRequirement) -> Self { + self.capabilities.push(capability); + self + } + + /// Sets the receipt policy. + pub fn receipts(mut self, policy: ReceiptPolicy) -> Self { + self.receipts = policy; + self + } + + /// Validates the declaration and builds its canonical manifest, or returns + /// every diagnostic. Same as [`validate`]. + pub fn validate(&self) -> Result> { + validate(self) + } +} diff --git a/packages/rs-dash-sdk-contract/src/declare/module.rs b/packages/rs-dash-sdk-contract/src/declare/module.rs new file mode 100644 index 00000000000..c8c711dc733 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/declare/module.rs @@ -0,0 +1,125 @@ +//! Named WASM modules and the interfaces between them. +//! +//! A contract package may build several WASM module targets. Each entry binds +//! to one module; interfaces declare the functions one module provides to +//! others. The manifest records the module names, the interfaces and the +//! resulting `(importer, provider)` bindings; the graph must be acyclic. A +//! single-module package needs no module declaration: the implicit module is +//! named `main`. + +use alloc::string::String; +use alloc::vec::Vec; + +use super::entry::{ParamSpec, ValueType}; +use super::DeclarationOrigin; +use crate::identity::{InterfaceName, ModuleName}; + +/// The name of the implicit module of a package that declares none. +/// Provisional. +pub const IMPLICIT_MODULE: &str = "main"; + +/// The implicit module as a [`ModuleName`]. +pub fn implicit_module() -> ModuleName { + ModuleName::new(IMPLICIT_MODULE).expect("the implicit module name satisfies the module grammar") +} + +/// A WASM module target. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ModuleSpec { + /// Where the spec came from. + pub origin: DeclarationOrigin, + /// The module's identity. + pub name: ModuleName, + /// Interfaces the module imports. + pub uses: Vec, +} + +impl ModuleSpec { + /// A module importing nothing. + pub fn new(name: ModuleName) -> Self { + ModuleSpec { + origin: DeclarationOrigin::Builder, + name, + uses: Vec::new(), + } + } + + /// Records the origin. + pub fn with_origin(mut self, origin: DeclarationOrigin) -> Self { + self.origin = origin; + self + } + + /// Imports an interface. + pub fn uses(mut self, interface: InterfaceName) -> Self { + self.uses.push(interface); + self + } +} + +/// A function of an interface. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct InternalFunctionSpec { + /// The function name, unique within the interface. + pub name: String, + /// Parameters. + pub params: Vec, + /// Return type. + pub returns: ValueType, +} + +/// An interface one module provides to others. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InterfaceSpec { + /// Where the spec came from. + pub origin: DeclarationOrigin, + /// The interface's identity. + pub name: InterfaceName, + /// The module exporting it. + pub provider: ModuleName, + /// Its functions. + pub functions: Vec, +} + +impl InterfaceSpec { + /// An empty interface provided by `provider`. + pub fn new(name: InterfaceName, provider: ModuleName) -> Self { + InterfaceSpec { + origin: DeclarationOrigin::Builder, + name, + provider, + functions: Vec::new(), + } + } + + /// Records the origin. + pub fn with_origin(mut self, origin: DeclarationOrigin) -> Self { + self.origin = origin; + self + } + + /// Adds a function. + pub fn function( + mut self, + name: impl Into, + params: Vec, + returns: ValueType, + ) -> Self { + self.functions.push(InternalFunctionSpec { + name: name.into(), + params, + returns, + }); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_name_the_implicit_module_main() { + assert_eq!(implicit_module().as_str(), IMPLICIT_MODULE); + } +} diff --git a/packages/rs-dash-sdk-contract/src/declare/rule.rs b/packages/rs-dash-sdk-contract/src/declare/rule.rs new file mode 100644 index 00000000000..5d1e2127a22 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/declare/rule.rs @@ -0,0 +1,371 @@ +//! Rules on ordinary document actions: native bounded guards and read-only +//! WASM predicates. +//! +//! [`ActionScope`] lists the ordinary document actions only. A contested-index +//! award is an internal native action that no guest rule, guard or predicate +//! can scope, veto, delay, redirect or retry; it is therefore unrepresentable +//! here, and the grammar rejects `on = "award"` as an invalid option value. +//! Ordinary action rules still apply to ordinary writes on a collection that +//! also declares a contested index. + +use alloc::boxed::Box; +use alloc::string::String; +use alloc::vec::Vec; + +use super::DeclarationOrigin; +use crate::identity::{CollectionName, ModuleName, PropertyPath, RuleName}; + +/// An ordinary document action. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ActionScope { + /// Document creation. + Create, + /// Document replacement. + Replace, + /// Document deletion. + Delete, + /// Document transfer. + Transfer, + /// Document purchase. + Purchase, + /// Price update. + UpdatePrice, +} + +impl ActionScope { + /// Every ordinary action, in canonical order. + pub const ALL: &'static [ActionScope] = &[ + ActionScope::Create, + ActionScope::Replace, + ActionScope::Delete, + ActionScope::Transfer, + ActionScope::Purchase, + ActionScope::UpdatePrice, + ]; + + /// The grammar spelling. + pub fn as_str(&self) -> &'static str { + match self { + ActionScope::Create => "create", + ActionScope::Replace => "replace", + ActionScope::Delete => "delete", + ActionScope::Transfer => "transfer", + ActionScope::Purchase => "purchase", + ActionScope::UpdatePrice => "update_price", + } + } +} + +impl core::fmt::Display for ActionScope { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Which document a guard field reads. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FieldContext { + /// The target immediately before the action. + Old, + /// The target as the action would leave it. + New, + /// The authenticated action context. + Context, +} + +/// A guard literal. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum Literal { + /// A boolean. + Bool(bool), + /// An integer, typed by the compared field's declared width. + Integer(i128), + /// A string. + Text(String), + /// Bytes. + Bytes(Vec), +} + +/// The author-facing native guard expression. +/// +/// Mirrors the proposed bounded guard node set: no loops, recursion, dynamic +/// traversal or ambient time. Provisional: the canonical guard AST and its +/// evaluator are specified by the guards work; the build tooling translates +/// this form into it. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum GuardExpr { + /// A literal. + Literal(Literal), + /// A field of the old, new or context document. + Field(FieldContext, PropertyPath), + /// Whether the field is present. + Exists(FieldContext, PropertyPath), + /// Whether the field is present and null. + IsNull(FieldContext, PropertyPath), + /// Equality of two typed operands. + Eq(Box, Box), + /// Inequality. + Ne(Box, Box), + /// Less than. + Lt(Box, Box), + /// Less than or equal. + Le(Box, Box), + /// Greater than. + Gt(Box, Box), + /// Greater than or equal. + Ge(Box, Box), + /// Checked addition. + Add(Box, Box), + /// Checked subtraction. + Sub(Box, Box), + /// Checked multiplication. + Mul(Box, Box), + /// Short-circuit conjunction. + And(Box, Box), + /// Short-circuit disjunction. + Or(Box, Box), + /// Negation. + Not(Box), + /// Conditional; one branch is evaluated. + If { + /// The condition. + condition: Box, + /// Evaluated when the condition holds. + then: Box, + /// Evaluated otherwise. + otherwise: Box, + }, +} + +impl GuardExpr { + /// An integer literal. + pub fn integer(value: i128) -> Self { + GuardExpr::Literal(Literal::Integer(value)) + } + + /// A boolean literal. + pub fn boolean(value: bool) -> Self { + GuardExpr::Literal(Literal::Bool(value)) + } + + /// A field read. + pub fn field(context: FieldContext, path: PropertyPath) -> Self { + GuardExpr::Field(context, path) + } + + /// `self == other` + pub fn eq(self, other: GuardExpr) -> Self { + GuardExpr::Eq(Box::new(self), Box::new(other)) + } + + /// `self != other` + pub fn ne(self, other: GuardExpr) -> Self { + GuardExpr::Ne(Box::new(self), Box::new(other)) + } + + /// `self < other` + pub fn lt(self, other: GuardExpr) -> Self { + GuardExpr::Lt(Box::new(self), Box::new(other)) + } + + /// `self <= other` + pub fn le(self, other: GuardExpr) -> Self { + GuardExpr::Le(Box::new(self), Box::new(other)) + } + + /// `self > other` + pub fn gt(self, other: GuardExpr) -> Self { + GuardExpr::Gt(Box::new(self), Box::new(other)) + } + + /// `self >= other` + pub fn ge(self, other: GuardExpr) -> Self { + GuardExpr::Ge(Box::new(self), Box::new(other)) + } + + /// `self + other`, checked. + pub fn plus(self, other: GuardExpr) -> Self { + GuardExpr::Add(Box::new(self), Box::new(other)) + } + + /// `self - other`, checked. + pub fn minus(self, other: GuardExpr) -> Self { + GuardExpr::Sub(Box::new(self), Box::new(other)) + } + + /// `self * other`, checked. + pub fn times(self, other: GuardExpr) -> Self { + GuardExpr::Mul(Box::new(self), Box::new(other)) + } + + /// `self && other` + pub fn and(self, other: GuardExpr) -> Self { + GuardExpr::And(Box::new(self), Box::new(other)) + } + + /// `self || other` + pub fn or(self, other: GuardExpr) -> Self { + GuardExpr::Or(Box::new(self), Box::new(other)) + } + + /// `!self` + pub fn negate(self) -> Self { + GuardExpr::Not(Box::new(self)) + } + + /// `if self { then } else { otherwise }` + pub fn if_else(self, then: GuardExpr, otherwise: GuardExpr) -> Self { + GuardExpr::If { + condition: Box::new(self), + then: Box::new(then), + otherwise: Box::new(otherwise), + } + } + + /// Every field path the expression reads, with its context. + pub fn field_references(&self) -> Vec<(FieldContext, &PropertyPath)> { + let mut references = Vec::new(); + self.collect_field_references(&mut references); + references + } + + fn collect_field_references<'a>(&'a self, into: &mut Vec<(FieldContext, &'a PropertyPath)>) { + match self { + GuardExpr::Literal(_) => {} + GuardExpr::Field(context, path) + | GuardExpr::Exists(context, path) + | GuardExpr::IsNull(context, path) => into.push((*context, path)), + GuardExpr::Eq(a, b) + | GuardExpr::Ne(a, b) + | GuardExpr::Lt(a, b) + | GuardExpr::Le(a, b) + | GuardExpr::Gt(a, b) + | GuardExpr::Ge(a, b) + | GuardExpr::Add(a, b) + | GuardExpr::Sub(a, b) + | GuardExpr::Mul(a, b) + | GuardExpr::And(a, b) + | GuardExpr::Or(a, b) => { + a.collect_field_references(into); + b.collect_field_references(into); + } + GuardExpr::Not(a) => a.collect_field_references(into), + GuardExpr::If { + condition, + then, + otherwise, + } => { + condition.collect_field_references(into); + then.collect_field_references(into); + otherwise.collect_field_references(into); + } + } + } +} + +/// How a rule decides. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum RuleKind { + /// A native bounded guard expression, evaluated by the host. + NativeGuard(GuardExpr), + /// A read-only predicate exported by a WASM module of this contract. + WasmPredicate { + /// The module exporting the predicate. + module: ModuleName, + /// The export symbol, bound against the module's actual exports at + /// build time. + export: String, + }, +} + +/// A rule on ordinary actions of one collection. Its identity is +/// `(collection, name)`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuleSpec { + /// Where the spec came from. + pub origin: DeclarationOrigin, + /// The rule's name, unique within the collection. + pub name: RuleName, + /// The guarded collection. + pub collection: CollectionName, + /// The guarded actions; stored as a sorted set in the manifest. + pub actions: Vec, + /// How the rule decides. + pub kind: RuleKind, +} + +impl RuleSpec { + /// A native guard rule. + pub fn guard( + collection: CollectionName, + name: RuleName, + actions: Vec, + guard: GuardExpr, + ) -> Self { + RuleSpec { + origin: DeclarationOrigin::Builder, + name, + collection, + actions, + kind: RuleKind::NativeGuard(guard), + } + } + + /// A WASM predicate rule. + pub fn predicate( + collection: CollectionName, + name: RuleName, + actions: Vec, + module: ModuleName, + export: impl Into, + ) -> Self { + RuleSpec { + origin: DeclarationOrigin::Builder, + name, + collection, + actions, + kind: RuleKind::WasmPredicate { + module, + export: export.into(), + }, + } + } + + /// Records the origin. + pub fn with_origin(mut self, origin: DeclarationOrigin) -> Self { + self.origin = origin; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_have_no_award_action() { + assert!(ActionScope::ALL + .iter() + .all(|action| action.as_str() != "award")); + assert_eq!(ActionScope::ALL.len(), 6); + } + + #[test] + fn should_collect_every_field_reference_of_a_guard() { + let points = PropertyPath::new("points").unwrap(); + let guard = GuardExpr::field(FieldContext::New, points.clone()) + .ge(GuardExpr::field(FieldContext::Old, points.clone())) + .and( + GuardExpr::Exists(FieldContext::Old, points.clone()) + .negate() + .if_else( + GuardExpr::boolean(true), + GuardExpr::integer(1).lt(GuardExpr::integer(2)), + ), + ); + let references = guard.field_references(); + assert_eq!(references.len(), 3); + assert_eq!(references[0], (FieldContext::New, &points)); + assert_eq!(references[2], (FieldContext::Old, &points)); + } +} diff --git a/packages/rs-dash-sdk-contract/src/grammar.rs b/packages/rs-dash-sdk-contract/src/grammar.rs new file mode 100644 index 00000000000..5e86e9916fa --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/grammar.rs @@ -0,0 +1,1582 @@ +//! The attribute grammar as data. +//! +//! [`ATTRIBUTES`] is the single description of every author attribute +//! (`#[persistent]`, `#[index]`, `#[entry]`, ...), the options each one takes +//! and the values those options accept. The proc macros parse against this +//! table, [`check_keys`] reports grammar diagnostics from it, and the book +//! chapter renders it, so the three cannot drift. +//! +//! The grammar is deliberately closed: an option that is not in the table is +//! an [`DiagnosticKind::UnknownOption`] diagnostic, never silently ignored. The +//! index property order admits only `"asc"` because the native document +//! meta-schema admits only ascending index properties; query direction is a +//! per-query choice. The rule action list admits only ordinary document actions +//! because a contested-index award is a native action that no rule can scope. +//! +//! The spellings themselves are provisional under the shared allocation +//! register entry for the Rust macro grammar. + +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use crate::validate::diagnostic::{DeclarationPath, Diagnostic, DiagnosticKind}; + +/// What a Rust item an attribute may be placed on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AttributeTarget { + /// A struct declaring a collection. + Struct, + /// A field of a persistent struct. + Field, + /// A free function or an inherent method. + Function, + /// A trait declaring an interface between modules. + Trait, + /// A Rust module compiled as one WASM module target. + Module, + /// The crate root: contract-wide declarations. + Crate, +} + +/// A closed set of string values an option accepts, with the reason the set +/// is closed (shown in the diagnostic when a value is outside it). +#[derive(Debug, PartialEq, Eq)] +pub struct Choices { + /// The admitted spellings. + pub allowed: &'static [&'static str], + /// Why nothing else is admitted. + pub explain: &'static str, +} + +/// The shape of an option's value. +#[derive(Debug, PartialEq, Eq)] +pub enum ValueShape { + /// A bare flag (`unique`) or an explicit boolean (`mutable = false`). + Bool, + /// Any string. + Str, + /// One string from a closed set. + Choice(&'static Choices), + /// An integer. + Int, + /// A list of strings. + StrList, + /// A list of strings from a closed set. + ChoiceList(&'static Choices), + /// A bare flag or one string from a closed set (`count` / `count = "offset"`). + BoolOrChoice(&'static Choices), + /// A bare flag or a list of strings (`ranked_count` / `ranked_count = ["a"]`). + BoolOrStrList, + /// A nested option list with its own keys (`contested(...)`). + Nested(&'static [KeySpec]), + /// A nested map whose keys are property paths (`fields(class = "asc")`). + Map(MapValue), +} + +/// The value shape of every entry in a [`ValueShape::Map`]. +#[derive(Debug, PartialEq, Eq)] +pub enum MapValue { + /// Any string. + Str, + /// One string from a closed set. + Choice(&'static Choices), +} + +/// One option of an attribute. +#[derive(Debug, PartialEq, Eq)] +pub struct KeySpec { + /// The option name. + pub name: &'static str, + /// The value shape. + pub value: ValueShape, + /// Whether the option must be present. + pub required: bool, + /// What the option means and its default when absent. + pub doc: &'static str, +} + +/// One attribute of the grammar. +#[derive(Debug, PartialEq, Eq)] +pub struct AttributeSpec { + /// The attribute name as written after `#[`. + pub name: &'static str, + /// Where it may be placed. + pub target: AttributeTarget, + /// Whether the attribute may appear several times on one item. + pub repeatable: bool, + /// The options it takes. + pub keys: &'static [KeySpec], + /// Groups of options of which exactly one must be present. + pub exactly_one_of: &'static [&'static [&'static str]], +} + +/// Index property order: ascending only. +pub const ORDER: Choices = Choices { + allowed: &["asc"], + explain: "native indexes are ascending only; the direction of a query is chosen per query", +}; + +/// Ordinary document actions a rule or a token cost may scope. +pub const ACTIONS: Choices = Choices { + allowed: &[ + "create", + "replace", + "delete", + "transfer", + "purchase", + "update_price", + ], + explain: "only ordinary document actions have a rule scope; a contested-index award is a native action that no guest rule, guard or predicate can scope", +}; + +/// Who may create documents of a collection. +pub const WRITE: Choices = Choices { + allowed: &["any", "owner", "contract"], + explain: "creation restriction modes; `contract` requires the pending native contract-write authority", +}; + +/// Marketplace trade mode. +pub const TRADE: Choices = Choices { + allowed: &["none", "direct_purchase"], + explain: "native trade modes", +}; + +/// Signature security level required to write. +pub const SECURITY_LEVEL: Choices = Choices { + allowed: &["critical", "high", "medium"], + explain: "native signature security levels", +}; + +/// Identity bounded key requirement for encryption or decryption. +pub const KEY_REQUIREMENT: Choices = Choices { + allowed: &["unique", "multiple", "multiple_reference_to_latest"], + explain: "native storage key requirements", +}; + +/// Document store kind. +pub const STORE: Choices = Choices { + allowed: &["public", "private"], + explain: "`private` is catalogued but its interface is disabled until encryption, key control, query visibility and proof behaviour are specified", +}; + +/// Count fast-path form on an index. +pub const COUNT: Choices = Choices { + allowed: &["offset"], + explain: + "`count` alone selects a count tree; `count = \"offset\"` selects a provable count tree", +}; + +/// Reference targets. +pub const REFERS_TO: Choices = Choices { + allowed: &[ + "identity", + "contract", + "token", + "permanent_document", + "identity_public_key", + ], + explain: "native reference targets", +}; + +/// Token cost effect. +pub const TOKEN_EFFECT: Choices = Choices { + allowed: &["transfer_to_contract_owner", "burn"], + explain: "native token cost effects", +}; + +/// Who pays gas for a token-priced action. +pub const GAS_PAID_BY: Choices = Choices { + allowed: &["document_owner", "contract_owner", "prefer_contract_owner"], + explain: "native gas payer options", +}; + +/// Contested index resolution. +pub const RESOLUTION: Choices = Choices { + allowed: &["masternode_vote"], + explain: "the only native contested resolution", +}; + +/// Receipt policy. +pub const RECEIPTS: Choices = Choices { + allowed: &["stored", "disabled"], + explain: "receipts default to stored and may be disabled by the contract", +}; + +/// Declarable capability requirements. +pub const REQUIRES: Choices = Choices { + allowed: &["acl", "randomness"], + explain: + "capabilities a contract declares explicitly; the others are derived from its declarations", +}; + +const CONTESTED_KEYS: &[KeySpec] = &[ + KeySpec { + name: "field_matches", + value: ValueShape::Map(MapValue::Str), + required: false, + doc: "index property to regular expression the value must match to be contested", + }, + KeySpec { + name: "resolution", + value: ValueShape::Choice(&RESOLUTION), + required: true, + doc: "how a contest is resolved", + }, + KeySpec { + name: "description", + value: ValueShape::Str, + required: false, + doc: "human description of the contest", + }, +]; + +const TIME_RANGE_KEYS: &[KeySpec] = &[ + KeySpec { + name: "on", + value: ValueShape::Str, + required: true, + doc: "the index's first property, a system timestamp", + }, + KeySpec { + name: "range_secs", + value: ValueShape::Int, + required: true, + doc: "window length in seconds, a multiple of `step_secs`", + }, + KeySpec { + name: "step_secs", + value: ValueShape::Int, + required: true, + doc: "interval between window starts in seconds", + }, + KeySpec { + name: "phase_secs", + value: ValueShape::Int, + required: false, + doc: "window grid offset in seconds, default 0", + }, + KeySpec { + name: "ttl_secs", + value: ValueShape::Int, + required: false, + doc: "time to live in seconds; entries expire this long after their bucket starts, default indefinite", + }, +]; + +const PERSISTENT_KEYS: &[KeySpec] = &[ + KeySpec { + name: "collection", + value: ValueShape::Str, + required: true, + doc: "collection name (native document type name); the collection's identity", + }, + KeySpec { + name: "schema", + value: ValueShape::Int, + required: false, + doc: "author-declared schema revision, at least 1, default 1", + }, + KeySpec { + name: "write", + value: ValueShape::Choice(&WRITE), + required: false, + doc: "who may create documents, default `any`", + }, + KeySpec { + name: "mutable", + value: ValueShape::Bool, + required: false, + doc: "documents may be replaced, default true", + }, + KeySpec { + name: "deletable", + value: ValueShape::Bool, + required: false, + doc: "documents may be deleted, default true", + }, + KeySpec { + name: "keep_history", + value: ValueShape::Bool, + required: false, + doc: "keep every revision, default false", + }, + KeySpec { + name: "keep_transfer_history", + value: ValueShape::Bool, + required: false, + doc: "keep transfer revisions, default false", + }, + KeySpec { + name: "keep_purchase_history", + value: ValueShape::Bool, + required: false, + doc: "keep purchase revisions, default false", + }, + KeySpec { + name: "keep_pricing_history", + value: ValueShape::Bool, + required: false, + doc: "keep price-update revisions, default false", + }, + KeySpec { + name: "transferable", + value: ValueShape::Bool, + required: false, + doc: "documents may be transferred, default false", + }, + KeySpec { + name: "trade", + value: ValueShape::Choice(&TRADE), + required: false, + doc: "marketplace mode, default `none`", + }, + KeySpec { + name: "security_level", + value: ValueShape::Choice(&SECURITY_LEVEL), + required: false, + doc: "signature security level required to write, default `high`", + }, + KeySpec { + name: "encryption_key", + value: ValueShape::Choice(&KEY_REQUIREMENT), + required: false, + doc: "identity encryption bounded key requirement, default none", + }, + KeySpec { + name: "decryption_key", + value: ValueShape::Choice(&KEY_REQUIREMENT), + required: false, + doc: "identity decryption bounded key requirement, default none", + }, + KeySpec { + name: "count", + value: ValueShape::Bool, + required: false, + doc: "count tree on the primary key, default false", + }, + KeySpec { + name: "range_count", + value: ValueShape::Bool, + required: false, + doc: "provable count on the primary key, needs `count`", + }, + KeySpec { + name: "sum", + value: ValueShape::Str, + required: false, + doc: "integer property summed on the primary key", + }, + KeySpec { + name: "range_sum", + value: ValueShape::Bool, + required: false, + doc: "provable sum on the primary key, needs `sum`", + }, + KeySpec { + name: "average", + value: ValueShape::Str, + required: false, + doc: "sugar for `count` plus `sum = `, expanded before the manifest", + }, + KeySpec { + name: "range_average", + value: ValueShape::Bool, + required: false, + doc: "sugar for `range_count` plus `range_sum`, expanded before the manifest", + }, + KeySpec { + name: "index_only", + value: ValueShape::Bool, + required: false, + doc: "documents live only in their indexes, default false", + }, + KeySpec { + name: "requires", + value: ValueShape::StrList, + required: false, + doc: "system properties every document carries, such as `$createdAt`; a time-range index on a system timestamp needs it here", + }, + KeySpec { + name: "store", + value: ValueShape::Choice(&STORE), + required: false, + doc: "document store, default `public`", + }, +]; + +const SINGLETON_KEYS: &[KeySpec] = &[ + KeySpec { + name: "collection", + value: ValueShape::Str, + required: true, + doc: "collection name; the singleton's identity", + }, + KeySpec { + name: "schema", + value: ValueShape::Int, + required: false, + doc: "author-declared schema revision, at least 1, default 1", + }, + KeySpec { + name: "write", + value: ValueShape::Choice(&WRITE), + required: false, + doc: "who may write the singleton, default `any`", + }, + KeySpec { + name: "security_level", + value: ValueShape::Choice(&SECURITY_LEVEL), + required: false, + doc: "signature security level required to write, default `high`", + }, + KeySpec { + name: "encryption_key", + value: ValueShape::Choice(&KEY_REQUIREMENT), + required: false, + doc: "identity encryption bounded key requirement, default none", + }, + KeySpec { + name: "decryption_key", + value: ValueShape::Choice(&KEY_REQUIREMENT), + required: false, + doc: "identity decryption bounded key requirement, default none", + }, + KeySpec { + name: "requires", + value: ValueShape::StrList, + required: false, + doc: "system properties the record carries, such as `$updatedAt`", + }, + KeySpec { + name: "store", + value: ValueShape::Choice(&STORE), + required: false, + doc: "document store, default `public`", + }, +]; + +const TOKEN_COST_KEYS: &[KeySpec] = &[ + KeySpec { + name: "on", + value: ValueShape::Choice(&ACTIONS), + required: true, + doc: "the priced action; one token cost per action", + }, + KeySpec { + name: "token_position", + value: ValueShape::Int, + required: true, + doc: "token position in the token contract", + }, + KeySpec { + name: "amount", + value: ValueShape::Int, + required: true, + doc: "token amount charged", + }, + KeySpec { + name: "contract", + value: ValueShape::Str, + required: false, + doc: "token contract id in base58, default the declaring contract", + }, + KeySpec { + name: "effect", + value: ValueShape::Choice(&TOKEN_EFFECT), + required: false, + doc: "default `transfer_to_contract_owner`", + }, + KeySpec { + name: "gas_paid_by", + value: ValueShape::Choice(&GAS_PAID_BY), + required: false, + doc: "default `document_owner`", + }, +]; + +const INDEX_KEYS: &[KeySpec] = &[ + KeySpec { + name: "name", + value: ValueShape::Str, + required: true, + doc: "index name; the index's identity within its collection", + }, + KeySpec { + name: "fields", + value: ValueShape::Map(MapValue::Choice(&ORDER)), + required: true, + doc: "indexed property paths in order; system properties are written as string keys", + }, + KeySpec { + name: "unique", + value: ValueShape::Bool, + required: false, + doc: "default false", + }, + KeySpec { + name: "null_searchable", + value: ValueShape::Bool, + required: false, + doc: "default true", + }, + KeySpec { + name: "contested", + value: ValueShape::Nested(CONTESTED_KEYS), + required: false, + doc: "contested index parameters", + }, + KeySpec { + name: "count", + value: ValueShape::BoolOrChoice(&COUNT), + required: false, + doc: "count tree; `= \"offset\"` for a provable count tree", + }, + KeySpec { + name: "range_count", + value: ValueShape::Bool, + required: false, + doc: "range counts, needs `count`", + }, + KeySpec { + name: "sum", + value: ValueShape::Str, + required: false, + doc: "integer property summed at the index", + }, + KeySpec { + name: "range_sum", + value: ValueShape::Bool, + required: false, + doc: "range sums, needs `sum`", + }, + KeySpec { + name: "average", + value: ValueShape::Str, + required: false, + doc: "sugar for `count` plus `sum = `, expanded before the manifest", + }, + KeySpec { + name: "range_average", + value: ValueShape::Bool, + required: false, + doc: "sugar for `range_count` plus `range_sum`, expanded before the manifest", + }, + KeySpec { + name: "ranked_count", + value: ValueShape::BoolOrStrList, + required: false, + doc: "count ranking at the terminal level, or at the named prefix levels", + }, + KeySpec { + name: "ranked_sum", + value: ValueShape::Bool, + required: false, + doc: "sum ranking at the terminal level", + }, + KeySpec { + name: "ranked_average", + value: ValueShape::Bool, + required: false, + doc: "average ranking at the terminal level", + }, + KeySpec { + name: "time_range", + value: ValueShape::Nested(TIME_RANGE_KEYS), + required: false, + doc: "bucket the first property into time windows", + }, + KeySpec { + name: "terminal", + value: ValueShape::Str, + required: false, + doc: "index-only member key property, default `$ownerId`", + }, + KeySpec { + name: "preallocated", + value: ValueShape::Bool, + required: false, + doc: "index-only path preallocation", + }, + KeySpec { + name: "skip_if_absent", + value: ValueShape::Bool, + required: false, + doc: "index-only conditional participation on the first property", + }, +]; + +const FIELD_KEYS: &[KeySpec] = &[ + KeySpec { + name: "position", + value: ValueShape::Int, + required: true, + doc: "stable serialization position, contiguous from 0 per nesting level", + }, + KeySpec { + name: "max_chars", + value: ValueShape::Int, + required: false, + doc: "string bound, required for strings", + }, + KeySpec { + name: "min_chars", + value: ValueShape::Int, + required: false, + doc: "string lower bound", + }, + KeySpec { + name: "max_len", + value: ValueShape::Int, + required: false, + doc: "byte array bound, required for byte arrays", + }, + KeySpec { + name: "min_len", + value: ValueShape::Int, + required: false, + doc: "byte array lower bound", + }, + KeySpec { + name: "min", + value: ValueShape::Int, + required: false, + doc: "integer lower bound, must fit the Rust type", + }, + KeySpec { + name: "max", + value: ValueShape::Int, + required: false, + doc: "integer upper bound, must fit the Rust type", + }, + KeySpec { + name: "values", + value: ValueShape::StrList, + required: false, + doc: "closed set of string values", + }, + KeySpec { + name: "required", + value: ValueShape::Bool, + required: false, + doc: "default true", + }, + KeySpec { + name: "transient", + value: ValueShape::Bool, + required: false, + doc: "validated but not stored, default false", + }, + KeySpec { + name: "refers_to", + value: ValueShape::Choice(&REFERS_TO), + required: false, + doc: "reference target of an identifier field", + }, + KeySpec { + name: "document_type", + value: ValueShape::Str, + required: false, + doc: "referenced collection for `permanent_document`", + }, + KeySpec { + name: "contract", + value: ValueShape::Str, + required: false, + doc: "referenced contract id in base58 for `permanent_document`, default the declaring contract", + }, + KeySpec { + name: "agreement", + value: ValueShape::Map(MapValue::Str), + required: false, + doc: "referring property to referenced property equalities for `permanent_document`", + }, + KeySpec { + name: "key_id_field", + value: ValueShape::Str, + required: false, + doc: "property carrying the key id for `identity_public_key`", + }, + KeySpec { + name: "description", + value: ValueShape::Str, + required: false, + doc: "human description", + }, +]; + +const ENTRY_KEYS: &[KeySpec] = &[ + KeySpec { + name: "name", + value: ValueShape::Str, + required: true, + doc: "method name; the entry's identity across the whole contract", + }, + KeySpec { + name: "read_only", + value: ValueShape::Bool, + required: false, + doc: "the entry stages no writes, default false", + }, + KeySpec { + name: "module", + value: ValueShape::Str, + required: false, + doc: "hosting WASM module, default the single module", + }, +]; + +const RULE_KEYS: &[KeySpec] = &[ + KeySpec { + name: "name", + value: ValueShape::Str, + required: true, + doc: "rule name, unique within the collection", + }, + KeySpec { + name: "on", + value: ValueShape::ChoiceList(&ACTIONS), + required: true, + doc: "ordinary actions the rule guards", + }, + KeySpec { + name: "guard", + value: ValueShape::Str, + required: false, + doc: "name of a native guard expression constant", + }, + KeySpec { + name: "predicate", + value: ValueShape::Str, + required: false, + doc: "read-only WASM predicate as `module::export`", + }, +]; + +const CONTRACT_KEYS: &[KeySpec] = &[ + KeySpec { + name: "receipts", + value: ValueShape::Choice(&RECEIPTS), + required: false, + doc: "default `stored`", + }, + KeySpec { + name: "requires", + value: ValueShape::ChoiceList(&REQUIRES), + required: false, + doc: "explicitly required capabilities", + }, +]; + +const MODULE_KEYS: &[KeySpec] = &[ + KeySpec { + name: "name", + value: ValueShape::Str, + required: true, + doc: "module name; the module's identity", + }, + KeySpec { + name: "uses", + value: ValueShape::StrList, + required: false, + doc: "interfaces imported from other modules", + }, +]; + +const INTERFACE_KEYS: &[KeySpec] = &[ + KeySpec { + name: "name", + value: ValueShape::Str, + required: true, + doc: "interface name; the interface's identity", + }, + KeySpec { + name: "provider", + value: ValueShape::Str, + required: true, + doc: "the module exporting the interface", + }, +]; + +/// Every attribute of the author grammar. +pub const ATTRIBUTES: &[AttributeSpec] = &[ + AttributeSpec { + name: "persistent", + target: AttributeTarget::Struct, + repeatable: false, + keys: PERSISTENT_KEYS, + exactly_one_of: &[], + }, + AttributeSpec { + name: "singleton", + target: AttributeTarget::Struct, + repeatable: false, + keys: SINGLETON_KEYS, + exactly_one_of: &[], + }, + AttributeSpec { + name: "token_cost", + target: AttributeTarget::Struct, + repeatable: true, + keys: TOKEN_COST_KEYS, + exactly_one_of: &[], + }, + AttributeSpec { + name: "index", + target: AttributeTarget::Struct, + repeatable: true, + keys: INDEX_KEYS, + exactly_one_of: &[], + }, + AttributeSpec { + name: "field", + target: AttributeTarget::Field, + repeatable: false, + keys: FIELD_KEYS, + exactly_one_of: &[], + }, + AttributeSpec { + name: "document_id", + target: AttributeTarget::Field, + repeatable: false, + keys: &[], + exactly_one_of: &[], + }, + AttributeSpec { + name: "entry", + target: AttributeTarget::Function, + repeatable: false, + keys: ENTRY_KEYS, + exactly_one_of: &[], + }, + AttributeSpec { + name: "rule", + target: AttributeTarget::Struct, + repeatable: true, + keys: RULE_KEYS, + exactly_one_of: &[&["guard", "predicate"]], + }, + AttributeSpec { + name: "contract", + target: AttributeTarget::Crate, + repeatable: false, + keys: CONTRACT_KEYS, + exactly_one_of: &[], + }, + AttributeSpec { + name: "module", + target: AttributeTarget::Module, + repeatable: false, + keys: MODULE_KEYS, + exactly_one_of: &[], + }, + AttributeSpec { + name: "interface", + target: AttributeTarget::Trait, + repeatable: false, + keys: INTERFACE_KEYS, + exactly_one_of: &[], + }, +]; + +/// Looks an attribute up by name. +pub fn attribute(name: &str) -> Option<&'static AttributeSpec> { + ATTRIBUTES.iter().find(|spec| spec.name == name) +} + +/// A value as the macro parsed it, before any typing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GivenValue<'a> { + /// A bare flag or an explicit boolean. + Bool(bool), + /// A string literal. + Str(&'a str), + /// An integer literal. + Int(i128), + /// A list of string literals. + StrList(&'a [&'a str]), + /// A nested option list or map. + Nested(&'a [GivenOption<'a>]), +} + +/// One option as the macro parsed it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GivenOption<'a> { + /// The option name (or the map key). + pub name: &'a str, + /// The value. + pub value: GivenValue<'a>, +} + +/// Checks the options given to `attribute` against the grammar and returns +/// every diagnostic: unknown attribute, unknown option, wrong value shape, +/// repeated option, missing required option, and violated exactly-one groups. +/// +/// The check is structural. Whether a value makes sense against the rest of +/// the declaration (a property path that exists, a bound inside the Rust type) +/// is the validator's job. +pub fn check_keys(attribute: &str, given: &[GivenOption<'_>]) -> Vec { + let mut diagnostics = Vec::new(); + let Some(spec) = self::attribute(attribute) else { + diagnostics.push(Diagnostic::new( + DeclarationPath::attribute(attribute, None), + DiagnosticKind::UnknownAttribute { + attribute: attribute.to_string(), + }, + )); + return diagnostics; + }; + check_options( + attribute, + spec.keys, + spec.exactly_one_of, + given, + &mut diagnostics, + ); + diagnostics +} + +fn check_options( + attribute: &str, + keys: &'static [KeySpec], + exactly_one_of: &'static [&'static [&'static str]], + given: &[GivenOption<'_>], + diagnostics: &mut Vec, +) { + let mut seen: Vec<&str> = Vec::new(); + for option in given { + let path = DeclarationPath::attribute(attribute, Some(option.name)); + let Some(key) = keys.iter().find(|key| key.name == option.name) else { + diagnostics.push(Diagnostic::new( + path, + DiagnosticKind::UnknownOption { + attribute: attribute.to_string(), + option: option.name.to_string(), + }, + )); + continue; + }; + if seen.contains(&option.name) { + diagnostics.push(Diagnostic::new( + path, + DiagnosticKind::DuplicateOption { + attribute: attribute.to_string(), + option: option.name.to_string(), + }, + )); + continue; + } + seen.push(option.name); + if let Err(reason) = check_value(attribute, key, option.value, diagnostics) { + diagnostics.push(Diagnostic::new( + path, + DiagnosticKind::InvalidOptionValue { + attribute: attribute.to_string(), + option: option.name.to_string(), + reason, + }, + )); + } + } + for key in keys.iter().filter(|key| key.required) { + if !seen.contains(&key.name) { + diagnostics.push(Diagnostic::new( + DeclarationPath::attribute(attribute, Some(key.name)), + DiagnosticKind::MissingOption { + attribute: attribute.to_string(), + option: key.name.to_string(), + }, + )); + } + } + for group in exactly_one_of { + let present = group.iter().filter(|name| seen.contains(name)).count(); + if present != 1 { + diagnostics.push(Diagnostic::new( + DeclarationPath::attribute(attribute, None), + DiagnosticKind::ExactlyOneOptionRequired { + attribute: attribute.to_string(), + options: group.iter().map(|name| name.to_string()).collect(), + given: present, + }, + )); + } + } +} + +fn choice_reason(value: &str, choices: &Choices) -> String { + let allowed: Vec = choices + .allowed + .iter() + .map(|allowed| format!("\"{allowed}\"")) + .collect(); + format!( + "value \"{value}\" is not one of [{}]: {}", + allowed.join(", "), + choices.explain + ) +} + +fn check_choice(value: &str, choices: &Choices) -> Result<(), String> { + if choices.allowed.contains(&value) { + Ok(()) + } else { + Err(choice_reason(value, choices)) + } +} + +fn check_value( + attribute: &str, + key: &'static KeySpec, + value: GivenValue<'_>, + diagnostics: &mut Vec, +) -> Result<(), String> { + match (&key.value, value) { + (ValueShape::Bool, GivenValue::Bool(_)) => Ok(()), + (ValueShape::Bool, _) => Err("expects a bare flag or `= true` / `= false`".to_string()), + (ValueShape::Str, GivenValue::Str(_)) => Ok(()), + (ValueShape::Str, _) => Err("expects a string".to_string()), + (ValueShape::Choice(choices), GivenValue::Str(value)) => check_choice(value, choices), + (ValueShape::Choice(_), _) => Err("expects a string".to_string()), + (ValueShape::Int, GivenValue::Int(_)) => Ok(()), + (ValueShape::Int, _) => Err("expects an integer".to_string()), + (ValueShape::StrList, GivenValue::StrList(_)) => Ok(()), + (ValueShape::StrList, _) => Err("expects a list of strings".to_string()), + (ValueShape::ChoiceList(choices), GivenValue::StrList(values)) => values + .iter() + .try_for_each(|value| check_choice(value, choices)), + (ValueShape::ChoiceList(_), _) => Err("expects a list of strings".to_string()), + (ValueShape::BoolOrChoice(_), GivenValue::Bool(_)) => Ok(()), + (ValueShape::BoolOrChoice(choices), GivenValue::Str(value)) => check_choice(value, choices), + (ValueShape::BoolOrChoice(_), _) => { + Err("expects a bare flag, a boolean or a string".to_string()) + } + (ValueShape::BoolOrStrList, GivenValue::Bool(_) | GivenValue::StrList(_)) => Ok(()), + (ValueShape::BoolOrStrList, _) => { + Err("expects a bare flag, a boolean or a list of strings".to_string()) + } + (ValueShape::Nested(keys), GivenValue::Nested(options)) => { + let nested = format!("{attribute}.{}", key.name); + check_options(&nested, keys, &[], options, diagnostics); + Ok(()) + } + (ValueShape::Nested(_), _) => Err("expects a nested option list".to_string()), + (ValueShape::Map(map_value), GivenValue::Nested(entries)) => { + if entries.is_empty() { + return Err("expects at least one entry".to_string()); + } + let mut seen: Vec<&str> = Vec::new(); + for entry in entries { + if seen.contains(&entry.name) { + return Err(format!("key {} is repeated", entry.name)); + } + seen.push(entry.name); + match (map_value, entry.value) { + (MapValue::Str, GivenValue::Str(_)) => {} + (MapValue::Choice(choices), GivenValue::Str(value)) => { + check_choice(value, choices)?; + } + _ => { + return Err(format!("key {} expects a string value", entry.name)); + } + } + } + Ok(()) + } + (ValueShape::Map(_), _) => Err("expects a nested map".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A snapshot of the grammar, attribute by attribute, so an unintended + /// option change fails here. The book chapter's table is checked against + /// `ATTRIBUTES` by the `book_table` integration test. + const GRAMMAR_SNAPSHOT: &[(&str, &[&str])] = &[ + ( + "persistent", + &[ + "collection", + "schema", + "write", + "mutable", + "deletable", + "keep_history", + "keep_transfer_history", + "keep_purchase_history", + "keep_pricing_history", + "transferable", + "trade", + "security_level", + "encryption_key", + "decryption_key", + "count", + "range_count", + "sum", + "range_sum", + "average", + "range_average", + "index_only", + "requires", + "store", + ], + ), + ( + "singleton", + &[ + "collection", + "schema", + "write", + "security_level", + "encryption_key", + "decryption_key", + "requires", + "store", + ], + ), + ( + "token_cost", + &[ + "on", + "token_position", + "amount", + "contract", + "effect", + "gas_paid_by", + ], + ), + ( + "index", + &[ + "name", + "fields", + "unique", + "null_searchable", + "contested", + "count", + "range_count", + "sum", + "range_sum", + "average", + "range_average", + "ranked_count", + "ranked_sum", + "ranked_average", + "time_range", + "terminal", + "preallocated", + "skip_if_absent", + ], + ), + ( + "field", + &[ + "position", + "max_chars", + "min_chars", + "max_len", + "min_len", + "min", + "max", + "values", + "required", + "transient", + "refers_to", + "document_type", + "contract", + "agreement", + "key_id_field", + "description", + ], + ), + ("document_id", &[]), + ("entry", &["name", "read_only", "module"]), + ("rule", &["name", "on", "guard", "predicate"]), + ("contract", &["receipts", "requires"]), + ("module", &["name", "uses"]), + ("interface", &["name", "provider"]), + ]; + + fn kinds(diagnostics: &[Diagnostic]) -> Vec<&'static str> { + diagnostics.iter().map(|d| d.kind.name()).collect() + } + + #[test] + fn should_match_the_grammar_snapshot() { + assert_eq!(ATTRIBUTES.len(), GRAMMAR_SNAPSHOT.len()); + for (name, options) in GRAMMAR_SNAPSHOT { + let spec = attribute(name).unwrap_or_else(|| panic!("attribute {name} missing")); + let declared: Vec<&str> = spec.keys.iter().map(|key| key.name).collect(); + assert_eq!(&declared, options, "options of {name}"); + } + } + + #[test] + fn should_keep_attribute_and_option_names_unique_and_groups_resolvable() { + let mut names: Vec<&str> = ATTRIBUTES.iter().map(|spec| spec.name).collect(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), ATTRIBUTES.len()); + for spec in ATTRIBUTES { + let mut keys: Vec<&str> = spec.keys.iter().map(|key| key.name).collect(); + keys.sort_unstable(); + keys.dedup(); + assert_eq!(keys.len(), spec.keys.len(), "options of {}", spec.name); + for group in spec.exactly_one_of { + for option in *group { + assert!(keys.contains(option), "{option} of {}", spec.name); + } + } + } + } + + #[test] + fn should_report_unknown_attribute() { + let diagnostics = check_keys("persisted", &[]); + assert_eq!(kinds(&diagnostics), ["UnknownAttribute"]); + assert_eq!(diagnostics[0].code(), "DSC0001"); + } + + #[test] + fn should_report_unknown_option() { + let diagnostics = check_keys( + "entry", + &[ + GivenOption { + name: "name", + value: GivenValue::Str("score.add"), + }, + GivenOption { + name: "readonly", + value: GivenValue::Bool(true), + }, + ], + ); + assert_eq!(kinds(&diagnostics), ["UnknownOption"]); + } + + #[test] + fn should_report_invalid_option_value_for_wrong_shape() { + let diagnostics = check_keys( + "entry", + &[GivenOption { + name: "name", + value: GivenValue::Int(1), + }], + ); + assert_eq!(kinds(&diagnostics), ["InvalidOptionValue"]); + } + + #[test] + fn should_report_missing_option() { + let diagnostics = check_keys("entry", &[]); + assert_eq!(kinds(&diagnostics), ["MissingOption"]); + } + + #[test] + fn should_report_duplicate_option() { + let diagnostics = check_keys( + "entry", + &[ + GivenOption { + name: "name", + value: GivenValue::Str("a"), + }, + GivenOption { + name: "name", + value: GivenValue::Str("b"), + }, + ], + ); + assert_eq!(kinds(&diagnostics), ["DuplicateOption"]); + } + + #[test] + fn should_reject_descending_index_fields_naming_the_native_rule() { + let fields = [ + GivenOption { + name: "class", + value: GivenValue::Str("asc"), + }, + GivenOption { + name: "points", + value: GivenValue::Str("desc"), + }, + ]; + let diagnostics = check_keys( + "index", + &[ + GivenOption { + name: "name", + value: GivenValue::Str("ranking"), + }, + GivenOption { + name: "fields", + value: GivenValue::Nested(&fields), + }, + ], + ); + assert_eq!(kinds(&diagnostics), ["InvalidOptionValue"]); + let DiagnosticKind::InvalidOptionValue { reason, .. } = &diagnostics[0].kind else { + panic!("expected an invalid option value"); + }; + assert!(reason.contains("ascending only"), "{reason}"); + } + + #[test] + fn should_reject_award_as_a_rule_action() { + let diagnostics = check_keys( + "rule", + &[ + GivenOption { + name: "name", + value: GivenValue::Str("no_award"), + }, + GivenOption { + name: "on", + value: GivenValue::StrList(&["create", "award"]), + }, + GivenOption { + name: "guard", + value: GivenValue::Str("GUARD"), + }, + ], + ); + assert_eq!(kinds(&diagnostics), ["InvalidOptionValue"]); + let DiagnosticKind::InvalidOptionValue { reason, .. } = &diagnostics[0].kind else { + panic!("expected an invalid option value"); + }; + assert!(reason.contains("award"), "{reason}"); + } + + #[test] + fn should_report_exactly_one_option_required_for_rule_kind() { + let both = check_keys( + "rule", + &[ + GivenOption { + name: "name", + value: GivenValue::Str("r"), + }, + GivenOption { + name: "on", + value: GivenValue::StrList(&["create"]), + }, + GivenOption { + name: "guard", + value: GivenValue::Str("GUARD"), + }, + GivenOption { + name: "predicate", + value: GivenValue::Str("main::check"), + }, + ], + ); + assert_eq!(kinds(&both), ["ExactlyOneOptionRequired"]); + let neither = check_keys( + "rule", + &[ + GivenOption { + name: "name", + value: GivenValue::Str("r"), + }, + GivenOption { + name: "on", + value: GivenValue::StrList(&["create"]), + }, + ], + ); + assert_eq!(kinds(&neither), ["ExactlyOneOptionRequired"]); + } + + #[test] + fn should_check_nested_options_under_their_dotted_attribute_name() { + let time_range = [ + GivenOption { + name: "on", + value: GivenValue::Str("$createdAt"), + }, + GivenOption { + name: "range_secs", + value: GivenValue::Int(3600), + }, + GivenOption { + name: "stepp", + value: GivenValue::Int(60), + }, + ]; + let fields = [GivenOption { + name: "$createdAt", + value: GivenValue::Str("asc"), + }]; + let diagnostics = check_keys( + "index", + &[ + GivenOption { + name: "name", + value: GivenValue::Str("recent"), + }, + GivenOption { + name: "fields", + value: GivenValue::Nested(&fields), + }, + GivenOption { + name: "time_range", + value: GivenValue::Nested(&time_range), + }, + ], + ); + assert_eq!(kinds(&diagnostics), ["UnknownOption", "MissingOption"]); + assert_eq!( + diagnostics[0].path().to_string(), + "attribute index.time_range, option stepp" + ); + } + + #[test] + fn should_accept_count_as_flag_and_as_offset_and_ranked_count_as_list() { + let fields = [GivenOption { + name: "class", + value: GivenValue::Str("asc"), + }]; + let diagnostics = check_keys( + "index", + &[ + GivenOption { + name: "name", + value: GivenValue::Str("by_class"), + }, + GivenOption { + name: "fields", + value: GivenValue::Nested(&fields), + }, + GivenOption { + name: "count", + value: GivenValue::Str("offset"), + }, + GivenOption { + name: "ranked_count", + value: GivenValue::StrList(&["class"]), + }, + ], + ); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + let diagnostics = check_keys( + "index", + &[ + GivenOption { + name: "name", + value: GivenValue::Str("by_class"), + }, + GivenOption { + name: "fields", + value: GivenValue::Nested(&fields), + }, + GivenOption { + name: "count", + value: GivenValue::Str("provable"), + }, + ], + ); + assert_eq!(kinds(&diagnostics), ["InvalidOptionValue"]); + } + + #[test] + fn should_reject_an_empty_or_repeated_fields_map() { + let empty: [GivenOption<'_>; 0] = []; + let diagnostics = check_keys( + "index", + &[ + GivenOption { + name: "name", + value: GivenValue::Str("x"), + }, + GivenOption { + name: "fields", + value: GivenValue::Nested(&empty), + }, + ], + ); + assert_eq!(kinds(&diagnostics), ["InvalidOptionValue"]); + let repeated = [ + GivenOption { + name: "class", + value: GivenValue::Str("asc"), + }, + GivenOption { + name: "class", + value: GivenValue::Str("asc"), + }, + ]; + let diagnostics = check_keys( + "index", + &[ + GivenOption { + name: "name", + value: GivenValue::Str("x"), + }, + GivenOption { + name: "fields", + value: GivenValue::Nested(&repeated), + }, + ], + ); + assert_eq!(kinds(&diagnostics), ["InvalidOptionValue"]); + } + + #[test] + fn should_accept_the_sketch_persistent_attribute() { + let diagnostics = check_keys( + "persistent", + &[ + GivenOption { + name: "collection", + value: GivenValue::Str("scores"), + }, + GivenOption { + name: "schema", + value: GivenValue::Int(1), + }, + GivenOption { + name: "write", + value: GivenValue::Str("contract"), + }, + ], + ); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + } + + #[test] + fn should_reject_a_private_store_value_outside_the_choice_set_but_accept_private() { + let private = check_keys( + "persistent", + &[ + GivenOption { + name: "collection", + value: GivenValue::Str("secrets"), + }, + GivenOption { + name: "store", + value: GivenValue::Str("private"), + }, + ], + ); + assert!(private.is_empty(), "{private:?}"); + let hidden = check_keys( + "persistent", + &[ + GivenOption { + name: "collection", + value: GivenValue::Str("secrets"), + }, + GivenOption { + name: "store", + value: GivenValue::Str("hidden"), + }, + ], + ); + assert_eq!(kinds(&hidden), ["InvalidOptionValue"]); + } +} diff --git a/packages/rs-dash-sdk-contract/src/identity.rs b/packages/rs-dash-sdk-contract/src/identity.rs new file mode 100644 index 00000000000..f9d601ecfff --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/identity.rs @@ -0,0 +1,525 @@ +//! Stable identities for collections, properties, indexes, methods, modules, +//! interfaces and rules. +//! +//! A declaration is identified by its declared name, never by the Rust path, +//! impl block, source module or declaration order that produced it. The +//! grammars here are the ones the manifest enforces; a name that fails its +//! grammar is reported by the validator as an `InvalidName` diagnostic. +//! +//! | Identity | Grammar | Source of the rule | +//! |---|---|---| +//! | [`CollectionName`] | `^[a-zA-Z0-9_-]{1,64}$` | native document type name rule | +//! | [`PropertyName`] | `^[a-zA-Z0-9_-]{1,64}$` | document meta-schema property names | +//! | [`PropertyPath`] | dotted property names, at most 256 bytes, or a system property | index property name limit | +//! | [`IndexName`] | 1 to 32 characters | document meta-schema index name | +//! | [`MethodName`] | `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$`, at most 64 bytes | provisional SDK rule | +//! | [`ModuleName`] | `^[a-z0-9_]{1,64}$` | provisional, aligned with the bundle validation crate | +//! | [`InterfaceName`] | `^[a-z0-9_]{1,64}$` | provisional SDK rule | +//! | [`RuleName`] | `^[a-z][a-z0-9_]{0,63}$` | provisional SDK rule | +//! +//! The method, module, interface and rule grammars are provisional values under +//! the shared allocation register (Rust macro grammar); the numeric method and +//! type identifiers derived from these names are allocated by the ABI work. + +use alloc::format; +use alloc::string::{String, ToString}; +use core::fmt; + +/// Maximum byte length of a collection or property name (native rule). +pub const MAX_NAME_BYTES: usize = 64; +/// Maximum byte length of an index property path (native meta-schema rule). +pub const MAX_PROPERTY_PATH_BYTES: usize = 256; +/// Maximum character count of an index name (native meta-schema rule). +pub const MAX_INDEX_NAME_CHARS: usize = 32; +/// Maximum byte length of a method name. Provisional. +pub const MAX_METHOD_NAME_BYTES: usize = 64; +/// Maximum byte length of a module or interface name. Provisional, aligned +/// with the bundle validation crate's module name bound. +pub const MAX_MODULE_NAME_BYTES: usize = 64; +/// Maximum byte length of a rule name. Provisional. +pub const MAX_RULE_NAME_BYTES: usize = 64; + +/// Prefix of every entry export symbol. Provisional. +pub const ENTRY_EXPORT_PREFIX: &str = "dash_entry_"; + +/// Document system properties that an index may name without the collection +/// declaring them. Whether a given system property is admissible for a given +/// document type (for example `$creatorId` needs a transferable type) is a +/// native rule and is not repeated here. +pub const SYSTEM_PROPERTIES: &[&str] = &[ + "$id", + "$ownerId", + "$creatorId", + "$createdAt", + "$updatedAt", + "$transferredAt", + "$createdAtBlockHeight", + "$updatedAtBlockHeight", + "$transferredAtBlockHeight", + "$createdAtCoreBlockHeight", + "$updatedAtCoreBlockHeight", + "$transferredAtCoreBlockHeight", +]; + +/// Returns whether `name` is one of the document system properties. +pub fn is_system_property(name: &str) -> bool { + SYSTEM_PROPERTIES.contains(&name) +} + +/// Which identity a name was checked against. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum NameKind { + /// A collection (native document type) name. + Collection, + /// A single property name. + Property, + /// A dotted property path or system property used by an index. + PropertyPath, + /// An index name. + Index, + /// A method (entry) name. + Method, + /// A WASM module name. + Module, + /// An interface name. + Interface, + /// A rule name. + Rule, +} + +impl fmt::Display for NameKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let text = match self { + NameKind::Collection => "collection", + NameKind::Property => "property", + NameKind::PropertyPath => "property path", + NameKind::Index => "index", + NameKind::Method => "method", + NameKind::Module => "module", + NameKind::Interface => "interface", + NameKind::Rule => "rule", + }; + f.write_str(text) + } +} + +/// A name that does not satisfy its identity grammar. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[error("invalid {kind} name {name:?}: {reason}")] +pub struct InvalidName { + /// The identity the name was checked against. + pub kind: NameKind, + /// The offending name, verbatim. + pub name: String, + /// Why the grammar rejected it. + pub reason: String, +} + +impl InvalidName { + fn new(kind: NameKind, name: &str, reason: impl Into) -> Self { + InvalidName { + kind, + name: name.to_string(), + reason: reason.into(), + } + } +} + +fn is_name_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' || c == '-' +} + +fn check_native_name(kind: NameKind, name: &str) -> Result<(), InvalidName> { + if name.is_empty() { + return Err(InvalidName::new(kind, name, "must not be empty")); + } + if name.len() > MAX_NAME_BYTES { + return Err(InvalidName::new( + kind, + name, + format!("longer than {MAX_NAME_BYTES} bytes"), + )); + } + if !name.chars().all(is_name_char) { + return Err(InvalidName::new( + kind, + name, + "only ASCII letters, digits, `_` and `-` are allowed", + )); + } + Ok(()) +} + +fn check_lower_segment(kind: NameKind, name: &str, segment: &str) -> Result<(), InvalidName> { + let mut chars = segment.chars(); + match chars.next() { + Some(first) if first.is_ascii_lowercase() => {} + _ => { + return Err(InvalidName::new( + kind, + name, + "each segment must start with a lowercase ASCII letter", + )) + } + } + if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') { + return Err(InvalidName::new( + kind, + name, + "only lowercase ASCII letters, digits and `_` are allowed after the first character", + )); + } + Ok(()) +} + +fn check_module_style_name(kind: NameKind, name: &str) -> Result<(), InvalidName> { + if name.is_empty() { + return Err(InvalidName::new(kind, name, "must not be empty")); + } + if name.len() > MAX_MODULE_NAME_BYTES { + return Err(InvalidName::new( + kind, + name, + format!("longer than {MAX_MODULE_NAME_BYTES} bytes"), + )); + } + if !name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') + { + return Err(InvalidName::new( + kind, + name, + "only lowercase ASCII letters, digits and `_` are allowed", + )); + } + Ok(()) +} + +macro_rules! name_newtype { + ($(#[$meta:meta])* $name:ident, $kind:expr, $check:expr) => { + $(#[$meta])* + #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct $name(String); + + impl $name { + /// Checks `name` against the grammar and wraps it. + pub fn new(name: impl AsRef) -> Result { + let name = name.as_ref(); + $check($kind, name)?; + Ok($name(name.to_string())) + } + + /// The name as declared. + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl AsRef for $name { + fn as_ref(&self) -> &str { + &self.0 + } + } + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } + } + + impl TryFrom<&str> for $name { + type Error = InvalidName; + + fn try_from(value: &str) -> Result { + $name::new(value) + } + } + + impl TryFrom for $name { + type Error = InvalidName; + + fn try_from(value: String) -> Result { + $name::new(value) + } + } + }; +} + +name_newtype!( + /// The identity of a collection: its declared name, which is also the native + /// document type name. Grammar `^[a-zA-Z0-9_-]{1,64}$`. + CollectionName, + NameKind::Collection, + check_native_name +); + +name_newtype!( + /// The identity of one property at one nesting level. Grammar + /// `^[a-zA-Z0-9_-]{1,64}$`. Together with its collection and position it + /// identifies a stored field. + PropertyName, + NameKind::Property, + check_native_name +); + +name_newtype!( + /// A dotted path of [`PropertyName`]s (`profile.age`), or one of the + /// [`SYSTEM_PROPERTIES`], as used by index definitions. At most 256 bytes. + PropertyPath, + NameKind::PropertyPath, + check_property_path +); + +name_newtype!( + /// The identity of an index within its collection: 1 to 32 characters, any + /// UTF-8. + IndexName, + NameKind::Index, + check_index_name +); + +name_newtype!( + /// The identity of an entry: unique across the whole contract regardless of + /// which module hosts it. Grammar `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$`, + /// at most 64 bytes, for example `score.add`. Provisional. + MethodName, + NameKind::Method, + check_method_name +); + +name_newtype!( + /// The name of one WASM module in a contract bundle. Grammar + /// `^[a-z0-9_]{1,64}$`. Provisional. + ModuleName, + NameKind::Module, + check_module_style_name +); + +name_newtype!( + /// The name of an interface a module provides to other modules. Grammar + /// `^[a-z0-9_]{1,64}$`. Provisional. + InterfaceName, + NameKind::Interface, + check_module_style_name +); + +name_newtype!( + /// The name of a rule, unique within its collection. Grammar + /// `^[a-z][a-z0-9_]{0,63}$`. Provisional. + RuleName, + NameKind::Rule, + check_rule_name +); + +fn check_property_path(kind: NameKind, path: &str) -> Result<(), InvalidName> { + if is_system_property(path) { + return Ok(()); + } + if path.is_empty() { + return Err(InvalidName::new(kind, path, "must not be empty")); + } + if path.len() > MAX_PROPERTY_PATH_BYTES { + return Err(InvalidName::new( + kind, + path, + format!("longer than {MAX_PROPERTY_PATH_BYTES} bytes"), + )); + } + for segment in path.split('.') { + check_native_name(NameKind::Property, segment).map_err(|error| { + InvalidName::new( + kind, + path, + format!("segment {:?}: {}", segment, error.reason), + ) + })?; + } + Ok(()) +} + +fn check_index_name(kind: NameKind, name: &str) -> Result<(), InvalidName> { + let chars = name.chars().count(); + if chars == 0 { + return Err(InvalidName::new(kind, name, "must not be empty")); + } + if chars > MAX_INDEX_NAME_CHARS { + return Err(InvalidName::new( + kind, + name, + format!("longer than {MAX_INDEX_NAME_CHARS} characters"), + )); + } + Ok(()) +} + +fn check_method_name(kind: NameKind, name: &str) -> Result<(), InvalidName> { + if name.is_empty() { + return Err(InvalidName::new(kind, name, "must not be empty")); + } + if name.len() > MAX_METHOD_NAME_BYTES { + return Err(InvalidName::new( + kind, + name, + format!("longer than {MAX_METHOD_NAME_BYTES} bytes"), + )); + } + for segment in name.split('.') { + check_lower_segment(kind, name, segment)?; + } + Ok(()) +} + +fn check_rule_name(kind: NameKind, name: &str) -> Result<(), InvalidName> { + if name.is_empty() { + return Err(InvalidName::new(kind, name, "must not be empty")); + } + if name.len() > MAX_RULE_NAME_BYTES { + return Err(InvalidName::new( + kind, + name, + format!("longer than {MAX_RULE_NAME_BYTES} bytes"), + )); + } + if name.contains('.') { + return Err(InvalidName::new(kind, name, "`.` is not allowed")); + } + check_lower_segment(kind, name, name) +} + +impl PropertyPath { + /// Returns whether the path names a document system property. + pub fn is_system(&self) -> bool { + is_system_property(&self.0) + } + + /// The path's segments; a system property is a single segment. + pub fn segments(&self) -> impl Iterator { + self.0.split('.') + } +} + +/// The WASM export symbol of an entry: the [`ENTRY_EXPORT_PREFIX`] followed by +/// the method name verbatim, so `score.add` exports as `dash_entry_score.add`. +/// +/// The mapping is a prefix plus the identity, which makes it injective by +/// construction (`a.b` and `a_b` stay distinct). WebAssembly export names are +/// arbitrary UTF-8 and Rust's `#[export_name]` accepts dots. Provisional. +pub fn entry_export_symbol(name: &MethodName) -> String { + format!("{ENTRY_EXPORT_PREFIX}{name}") +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec::Vec; + + #[test] + fn should_accept_collection_names_at_the_boundary() { + let longest: String = core::iter::repeat_n('a', MAX_NAME_BYTES).collect(); + assert!(CollectionName::new(&longest).is_ok()); + assert!(CollectionName::new("scores-v1_2").is_ok()); + assert!(CollectionName::new("A").is_ok()); + } + + #[test] + fn should_reject_collection_names_over_the_boundary_or_with_bad_characters() { + let too_long: String = core::iter::repeat_n('a', MAX_NAME_BYTES + 1).collect(); + let error = CollectionName::new(&too_long).unwrap_err(); + assert_eq!(error.kind, NameKind::Collection); + assert!(CollectionName::new("").is_err()); + assert!(CollectionName::new("scores.v1").is_err()); + assert!(CollectionName::new("scores v1").is_err()); + assert!(CollectionName::new("scörés").is_err()); + } + + #[test] + fn should_accept_property_paths_and_system_properties() { + assert!(PropertyPath::new("class").is_ok()); + assert!(PropertyPath::new("profile.age").is_ok()); + assert!(PropertyPath::new("$ownerId").unwrap().is_system()); + assert!(PropertyPath::new("$createdAt").is_ok()); + let dotted = PropertyPath::new("a.b.c").unwrap(); + let segments: Vec<&str> = dotted.segments().collect(); + assert_eq!(segments, ["a", "b", "c"]); + } + + #[test] + fn should_reject_property_paths_with_empty_segments_or_unknown_system_names() { + assert!(PropertyPath::new("a..b").is_err()); + assert!(PropertyPath::new(".a").is_err()); + assert!(PropertyPath::new("$unknown").is_err()); + assert!(PropertyPath::new("").is_err()); + let too_long: String = + core::iter::repeat_n("abcdefgh.", 29).collect::() + "abcdefgh"; + assert!(too_long.len() > MAX_PROPERTY_PATH_BYTES); + assert!(PropertyPath::new(&too_long).is_err()); + } + + #[test] + fn should_bound_index_names_by_characters_not_bytes() { + let thirty_two: String = core::iter::repeat_n('ü', MAX_INDEX_NAME_CHARS).collect(); + assert!(IndexName::new(&thirty_two).is_ok()); + let thirty_three: String = core::iter::repeat_n('ü', MAX_INDEX_NAME_CHARS + 1).collect(); + assert!(IndexName::new(&thirty_three).is_err()); + assert!(IndexName::new("").is_err()); + } + + #[test] + fn should_accept_dotted_method_names_and_reject_bad_segments() { + assert!(MethodName::new("score.add").is_ok()); + assert!(MethodName::new("a").is_ok()); + assert!(MethodName::new("score.add_pair2").is_ok()); + assert!(MethodName::new("Score.add").is_err()); + assert!(MethodName::new("score..add").is_err()); + assert!(MethodName::new("score.").is_err()); + assert!(MethodName::new("1score").is_err()); + assert!(MethodName::new("score-add").is_err()); + let too_long: String = core::iter::repeat_n('a', MAX_METHOD_NAME_BYTES + 1).collect(); + assert!(MethodName::new(&too_long).is_err()); + } + + #[test] + fn should_accept_module_and_interface_names_and_reject_uppercase() { + assert!(ModuleName::new("main").is_ok()); + assert!(ModuleName::new("helpers_2").is_ok()); + assert!(ModuleName::new("Main").is_err()); + assert!(ModuleName::new("").is_err()); + assert!(InterfaceName::new("math").is_ok()); + assert!(InterfaceName::new("math.v1").is_err()); + } + + #[test] + fn should_accept_rule_names_and_reject_dots() { + assert!(RuleName::new("points_monotonic").is_ok()); + assert!(RuleName::new("p").is_ok()); + assert!(RuleName::new("points.monotonic").is_err()); + assert!(RuleName::new("_points").is_err()); + let too_long: String = core::iter::repeat_n('a', MAX_RULE_NAME_BYTES + 1).collect(); + assert!(RuleName::new(&too_long).is_err()); + } + + #[test] + fn should_export_entries_under_the_prefixed_method_name() { + let name = MethodName::new("score.add").unwrap(); + assert_eq!(entry_export_symbol(&name), "dash_entry_score.add"); + } + + #[test] + fn should_keep_dotted_and_underscored_method_names_distinct_in_exports() { + let dotted = MethodName::new("a.b").unwrap(); + let underscored = MethodName::new("a__b").unwrap(); + assert_ne!( + entry_export_symbol(&dotted), + entry_export_symbol(&underscored) + ); + } + + #[test] + fn should_render_invalid_name_errors_with_kind_and_reason() { + let error = CollectionName::new("bad name").unwrap_err(); + let text = alloc::format!("{error}"); + assert!(text.contains("collection")); + assert!(text.contains("bad name")); + } +} diff --git a/packages/rs-dash-sdk-contract/src/lib.rs b/packages/rs-dash-sdk-contract/src/lib.rs new file mode 100644 index 00000000000..de3767ac9d7 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/lib.rs @@ -0,0 +1,45 @@ +//! Contract-author SDK for DashVM: the declaration model behind +//! `#[persistent]`, `#[index]`, `#[rule]` and `#[entry]`, the attribute +//! grammar those macros implement, the diagnostics they report, and the +//! canonical manifest a contract package publishes. +//! +//! This crate specifies the author-facing model. It carries no proc macros, no +//! host context and no runtime; those are later deliverables of the same +//! workstream and consume the types defined here: +//! +//! - [`grammar`] is the attribute grammar as data: which attributes exist, which +//! options they take and which values are legal. The proc macros, this +//! crate's validator and the book chapter share this single table. +//! - [`declare`] is the typed declaration model: [`declare::ContractDeclaration`] +//! gathers collections, indexes, rules, entries, modules, interfaces, typed +//! collections and capability requirements from attributes and from builders. +//! - [`validate`] turns a declaration into a [`manifest::CanonicalManifest`] or +//! into a complete list of [`validate::Diagnostic`]s. It owns grammar, +//! identity, boundedness, conflict and SDK-semantic checks. Native numeric +//! limits (index counts, name lengths, indexed string sizes) are deliberately +//! not duplicated: Dash Platform Protocol enforces them once and the build +//! crate surfaces them. +//! - [`manifest`] is the sorted, order-independent canonical manifest. It has no +//! wire encoding yet; the encoding and the numeric identifiers are allocated +//! by the ABI work. +//! - [`persistence`] states the persistence semantics as enums so that generated +//! wrappers and the documentation cannot drift from the confirmed policy: +//! detached values never save, explicit operations and successful mutable +//! receivers stage writes, nothing saves on `Drop`. +//! +//! The crate compiles without `std` (`--no-default-features`) on +//! `wasm32v1-none`, which is how guest packages depend on it. + +#![cfg_attr(not(feature = "std"), no_std)] +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +extern crate alloc; + +pub mod declare; +pub mod grammar; +pub mod identity; +pub mod manifest; +pub mod persistence; +pub mod prelude; +pub mod validate; diff --git a/packages/rs-dash-sdk-contract/src/manifest/bundle.rs b/packages/rs-dash-sdk-contract/src/manifest/bundle.rs new file mode 100644 index 00000000000..62fa625dab6 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/manifest/bundle.rs @@ -0,0 +1,56 @@ +//! Module, interface and binding tables. + +use alloc::vec::Vec; + +use crate::declare::InternalFunctionSpec; +use crate::identity::{InterfaceName, ModuleName}; + +/// One module of the bundle. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ModuleEntry { + /// The module's identity. + pub name: ModuleName, + /// Interfaces it imports, sorted. + pub uses: Vec, +} + +/// One interface of the bundle. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InterfaceEntry { + /// The interface's identity. + pub name: InterfaceName, + /// The providing module. + pub provider: ModuleName, + /// Its functions, sorted by name. + pub functions: Vec, +} + +/// An importer-to-provider binding through an interface. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Binding { + /// The importing module. + pub importer: ModuleName, + /// The providing module. + pub provider: ModuleName, + /// The interface. + pub interface: InterfaceName, +} + +/// The module table. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ModuleTable { + /// Modules, sorted by name. Never empty: a package that declares no + /// module has the implicit `main`. + pub modules: Vec, + /// Interfaces, sorted by name. + pub interfaces: Vec, + /// Bindings, sorted by `(importer, provider, interface)`. + pub bindings: Vec, +} + +impl ModuleTable { + /// The module names. + pub fn names(&self) -> impl Iterator { + self.modules.iter().map(|module| &module.name) + } +} diff --git a/packages/rs-dash-sdk-contract/src/manifest/capability.rs b/packages/rs-dash-sdk-contract/src/manifest/capability.rs new file mode 100644 index 00000000000..c4e344bbd2f --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/manifest/capability.rs @@ -0,0 +1,37 @@ +//! The capability table. + +use alloc::vec::Vec; + +use crate::declare::{CapabilityRequirement, CapabilityStatus}; + +/// One required capability with its catalogue status. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct CapabilityEntry { + /// The requirement. + pub requirement: CapabilityRequirement, + /// Its status in the catalogue. + pub status: CapabilityStatus, +} + +/// Every capability the contract needs, explicit and derived, sorted. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CapabilityTable { + /// The entries. + pub entries: Vec, +} + +impl CapabilityTable { + /// Whether the table contains the requirement. + pub fn requires(&self, requirement: CapabilityRequirement) -> bool { + self.entries + .iter() + .any(|entry| entry.requirement == requirement) + } + + /// The requirements whose status is not [`CapabilityStatus::Native`]. + pub fn pending(&self) -> impl Iterator { + self.entries + .iter() + .filter(|entry| entry.status != CapabilityStatus::Native) + } +} diff --git a/packages/rs-dash-sdk-contract/src/manifest/collection.rs b/packages/rs-dash-sdk-contract/src/manifest/collection.rs new file mode 100644 index 00000000000..67ce33049f4 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/manifest/collection.rs @@ -0,0 +1,136 @@ +//! Collection, index, typed collection and rule manifests. + +use alloc::vec::Vec; + +use crate::declare::{ + ActionScope, BoundedKeyRequirement, CollectionKind, ContestedSpec, Countability, FieldSpec, + IndexOnlySpec, Ranking, RuleKind, SecurityLevel, Store, TimeRangeSpec, TokenCostSpec, + TradeMode, TypedCollectionKind, ValueType, WritePolicy, +}; +use crate::identity::{CollectionName, IndexName, PropertyName, PropertyPath, RuleName}; + +/// One index, sugar expanded. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IndexManifest { + /// The index's identity within its collection. + pub name: IndexName, + /// Indexed property paths in order, all ascending. + pub properties: Vec, + /// Unique index. + pub unique: bool, + /// Null values are searchable. + pub null_searchable: bool, + /// Contested parameters, field matches sorted by property. + pub contested: Option, + /// Count fast path. + pub count: Countability, + /// Range counts. + pub range_count: bool, + /// Integer property summed at the index. + pub sum: Option, + /// Range sums. + pub range_sum: bool, + /// Ranking axes. + pub ranked: Ranking, + /// Time range bucketing. + pub time_range: Option, + /// Index-only options. + pub index_only: Option, +} + +/// One document collection or singleton, sugar expanded. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CollectionManifest { + /// The collection's identity and native document type name. + pub name: CollectionName, + /// Documents or singleton. + pub kind: CollectionKind, + /// Author-declared schema revision. + pub schema_revision: u32, + /// Who may create documents. + pub write: WritePolicy, + /// Documents may be replaced. + pub mutable: bool, + /// Documents may be deleted. + pub deletable: bool, + /// Every revision is kept. + pub keep_history: bool, + /// Transfer revisions are kept. + pub keep_transfer_history: bool, + /// Purchase revisions are kept. + pub keep_purchase_history: bool, + /// Price-update revisions are kept. + pub keep_pricing_history: bool, + /// Documents may be transferred. + pub transferable: bool, + /// Marketplace mode. + pub trade: TradeMode, + /// Signature security level required to write. + pub security_level: SecurityLevel, + /// Identity encryption bounded key requirement. + pub encryption_key: Option, + /// Identity decryption bounded key requirement. + pub decryption_key: Option, + /// Count tree on the primary key. + pub count: bool, + /// Provable count on the primary key. + pub range_count: bool, + /// Integer property summed on the primary key. + pub sum: Option, + /// Provable sum on the primary key. + pub range_sum: bool, + /// Documents live only in their indexes. + pub index_only: bool, + /// Required system properties, sorted. + pub requires: Vec, + /// Token prices, sorted by action. + pub token_costs: Vec, + /// Document store. + pub store: Store, + /// Stored fields, sorted by position at every level. + pub fields: Vec, + /// Indexes, sorted by name. + pub indexes: Vec, +} + +impl CollectionManifest { + /// Looks an index up by name. + pub fn index(&self, name: &str) -> Option<&IndexManifest> { + self.indexes + .iter() + .find(|index| index.name.as_str() == name) + } + + /// Looks a top-level field up by name. + pub fn field(&self, name: &str) -> Option<&FieldSpec> { + self.fields.iter().find(|field| field.name.as_str() == name) + } +} + +/// One typed specialized collection. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TypedCollectionManifest { + /// The collection's identity. + pub id: CollectionName, + /// The tree family. + pub kind: TypedCollectionKind, + /// The key type. + pub key: ValueType, + /// The element type. + pub element: ValueType, + /// Upper bound on the number of elements, when declared. + pub max_elements: Option, +} + +/// One rule. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuleManifest { + /// The guarded collection. + pub collection: CollectionName, + /// The rule's name within the collection. + pub name: RuleName, + /// The guarded actions, sorted and deduplicated. + pub actions: Vec, + /// How the rule decides. + pub kind: RuleKind, +} diff --git a/packages/rs-dash-sdk-contract/src/manifest/method.rs b/packages/rs-dash-sdk-contract/src/manifest/method.rs new file mode 100644 index 00000000000..2dd1ddef555 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/manifest/method.rs @@ -0,0 +1,63 @@ +//! The method table. + +use alloc::string::String; +use alloc::vec::Vec; + +use crate::declare::{CollectionKind, ParamSpec, Receiver, ValueType}; +use crate::identity::{MethodName, ModuleName}; + +/// One entry of the contract. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MethodEntry { + /// The entry's identity. + pub name: MethodName, + /// The hosting module: a binding, not part of the identity. + pub module: ModuleName, + /// The WASM export symbol, derived from the name. + pub export: String, + /// The receiver. + pub receiver: Receiver, + /// Whether the receiver is addressed by a document id on the wire. True + /// for a receiver on a document collection, false for a free entry or a + /// singleton receiver (the singleton's key is reserved and host known). + pub takes_document_id: bool, + /// Whether the entry stages no writes. + pub read_only: bool, + /// Wire parameters after the document id, if any. + pub params: Vec, + /// Wire return type. + pub returns: ValueType, +} + +impl MethodEntry { + /// Whether a receiver on a collection of the given kind is addressed by a + /// document id on the wire. + pub(crate) fn receiver_takes_document_id( + receiver: &Receiver, + kind: Option, + ) -> bool { + matches!(receiver, Receiver::Ref(_) | Receiver::Mut(_)) + && kind == Some(CollectionKind::Documents) + } +} + +/// Every entry, sorted by method name. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MethodTable { + /// The entries. + pub entries: Vec, +} + +impl MethodTable { + /// Looks an entry up by method name. + pub fn entry(&self, name: &str) -> Option<&MethodEntry> { + self.entries + .iter() + .find(|entry| entry.name.as_str() == name) + } + + /// The method names, in canonical order. + pub fn names(&self) -> impl Iterator { + self.entries.iter().map(|entry| &entry.name) + } +} diff --git a/packages/rs-dash-sdk-contract/src/manifest/mod.rs b/packages/rs-dash-sdk-contract/src/manifest/mod.rs new file mode 100644 index 00000000000..beb747f720b --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/manifest/mod.rs @@ -0,0 +1,94 @@ +//! The canonical manifest: the sorted, order-independent description of a +//! validated contract package. +//! +//! Every table is keyed by a required unique identity (name, position or a +//! tuple of names), so no ordering depends on declaration order, attribute +//! versus builder origin, or which Rust module hosted an item. Two +//! declarations that differ only in those respects produce equal manifests. +//! Sugar (`average`, `range_average`) is expanded before the manifest, which +//! therefore has no average fields. +//! +//! A manifest is constructed only by [`crate::validate::validate`]. It has no +//! wire encoding and no digest here: the encoding and the numeric method and +//! type identifiers are allocated by the ABI work, and this module is the +//! provisional home of the shape until then. + +pub mod bundle; +pub mod capability; +pub mod collection; +pub mod method; + +use alloc::vec::Vec; + +pub use bundle::{Binding, InterfaceEntry, ModuleEntry, ModuleTable}; +pub use capability::{CapabilityEntry, CapabilityTable}; +pub use collection::{CollectionManifest, IndexManifest, RuleManifest, TypedCollectionManifest}; +pub use method::{MethodEntry, MethodTable}; + +use crate::declare::ReceiptPolicy; + +/// The canonical manifest of one contract package. +/// +/// The tables are read through accessors only: a manifest exists solely as +/// the output of validation, and its invariants (a non-empty module table, +/// derived capabilities matching the declarations) hold because nothing +/// outside the validator can construct or edit one. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CanonicalManifest { + pub(crate) modules: ModuleTable, + pub(crate) collections: Vec, + pub(crate) typed_collections: Vec, + pub(crate) methods: MethodTable, + pub(crate) rules: Vec, + pub(crate) capabilities: CapabilityTable, + pub(crate) receipts: ReceiptPolicy, +} + +impl CanonicalManifest { + /// Modules, interfaces and bindings. + pub fn modules(&self) -> &ModuleTable { + &self.modules + } + + /// Document collections and singletons, sorted by name. + pub fn collections(&self) -> &[CollectionManifest] { + &self.collections + } + + /// Typed specialized collections, sorted by id. + pub fn typed_collections(&self) -> &[TypedCollectionManifest] { + &self.typed_collections + } + + /// Entries, sorted by method name. + pub fn methods(&self) -> &MethodTable { + &self.methods + } + + /// Rules, sorted by `(collection, name)`. + pub fn rules(&self) -> &[RuleManifest] { + &self.rules + } + + /// Required capabilities, explicit and derived, sorted. + pub fn capabilities(&self) -> &CapabilityTable { + &self.capabilities + } + + /// Receipt policy. + pub fn receipts(&self) -> ReceiptPolicy { + self.receipts + } + + /// Looks a collection up by name. + pub fn collection(&self, name: &str) -> Option<&CollectionManifest> { + self.collections + .iter() + .find(|collection| collection.name.as_str() == name) + } + + /// Looks an entry up by method name. + pub fn method(&self, name: &str) -> Option<&MethodEntry> { + self.methods.entry(name) + } +} diff --git a/packages/rs-dash-sdk-contract/src/persistence.rs b/packages/rs-dash-sdk-contract/src/persistence.rs new file mode 100644 index 00000000000..a7ede3c55c7 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/persistence.rs @@ -0,0 +1,114 @@ +//! Persistence semantics, stated as types so that generated wrappers and the +//! documentation cannot drift from the confirmed policy. +//! +//! Detached Rust values do not save automatically. Explicit insert and edit +//! operations, and the successful return of an exported mutable-receiver +//! wrapper, stage writes in the outer transaction. Nothing saves on `Drop`. +//! Document ids, revisions, owners and storage flags are host managed. + +use crate::declare::Receiver; + +/// The only points at which a write is staged. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum StagingPoint { + /// `documents::().insert(value)`: stages a native create, including + /// index maintenance and validation. + ExplicitInsert, + /// `documents::().edit(id, closure)`: loads one record, invokes the + /// closure and stages the update only when the closure returns + /// successfully. + ExplicitEdit, + /// An exported `&mut self` entry returning successfully: the generated + /// wrapper loads exactly the addressed record, invokes the method and + /// stages the receiver update. It never commits independently and never + /// loads the whole collection. + MutReceiverOnOk, +} + +impl StagingPoint { + /// Every staging point. + pub const ALL: &'static [StagingPoint] = &[ + StagingPoint::ExplicitInsert, + StagingPoint::ExplicitEdit, + StagingPoint::MutReceiverOnOk, + ]; + + /// The staging point an entry receiver implies, if any. + pub fn for_receiver(receiver: &Receiver) -> Option { + match receiver { + Receiver::Mut(_) => Some(StagingPoint::MutReceiverOnOk), + Receiver::None | Receiver::Ref(_) => None, + } + } +} + +/// Things that never stage a write. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum NeverStages { + /// Constructing or mutating a detached value. + DetachedValue, + /// Dropping a value, loaded or detached. + Drop, + /// A mutable reference escaping a call. + EscapedReference, + /// Calling an unmarked helper on a local value, whatever it mutates. + UnmarkedHelper, +} + +impl NeverStages { + /// Every case. + pub const ALL: &'static [NeverStages] = &[ + NeverStages::DetachedValue, + NeverStages::Drop, + NeverStages::EscapedReference, + NeverStages::UnmarkedHelper, + ]; +} + +/// Record attributes the host controls; a successful update cannot change +/// them through a mutable field. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum HostManaged { + /// The document id. + DocumentId, + /// The revision. + Revision, + /// The owner. + Owner, + /// Storage flags. + StorageFlags, +} + +impl HostManaged { + /// Every host-managed attribute. + pub const ALL: &'static [HostManaged] = &[ + HostManaged::DocumentId, + HostManaged::Revision, + HostManaged::Owner, + HostManaged::StorageFlags, + ]; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identity::CollectionName; + + #[test] + fn should_stage_only_on_a_mutable_receiver() { + let scores = CollectionName::new("scores").unwrap(); + assert_eq!( + StagingPoint::for_receiver(&Receiver::Mut(scores.clone())), + Some(StagingPoint::MutReceiverOnOk) + ); + assert_eq!(StagingPoint::for_receiver(&Receiver::Ref(scores)), None); + assert_eq!(StagingPoint::for_receiver(&Receiver::None), None); + } + + #[test] + fn should_list_drop_among_the_cases_that_never_stage() { + assert!(NeverStages::ALL.contains(&NeverStages::Drop)); + assert_eq!(StagingPoint::ALL.len(), 3); + assert_eq!(HostManaged::ALL.len(), 4); + } +} diff --git a/packages/rs-dash-sdk-contract/src/prelude.rs b/packages/rs-dash-sdk-contract/src/prelude.rs new file mode 100644 index 00000000000..d21082a8e4d --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/prelude.rs @@ -0,0 +1,18 @@ +//! Re-exports of the declaration types and builders. + +pub use crate::declare::{ + ActionScope, BoundedKeyRequirement, CapabilityRequirement, CapabilityStatus, CollectionKind, + CollectionSpec, ContestedResolution, ContestedSpec, ContractDeclaration, Countability, + DeclarationOrigin, EntrySpec, FieldContext, FieldSpec, FieldType, GasPaidBy, GuardExpr, + IndexOnlySpec, IndexSpec, IntegerBounds, IntegerWidth, InterfaceSpec, InternalFunctionSpec, + Literal, ModuleSpec, ParamSpec, RankedCount, Ranking, ReceiptPolicy, Receiver, ReferenceTarget, + RuleKind, RuleSpec, SecurityLevel, Store, TimeRangeSpec, TokenCost, TokenCostEffect, + TokenCostSpec, TradeMode, TypedCollectionKind, TypedCollectionSpec, ValueType, WritePolicy, +}; +pub use crate::identity::{ + entry_export_symbol, CollectionName, IndexName, InterfaceName, InvalidName, MethodName, + ModuleName, NameKind, PropertyName, PropertyPath, RuleName, +}; +pub use crate::manifest::CanonicalManifest; +pub use crate::persistence::{HostManaged, NeverStages, StagingPoint}; +pub use crate::validate::{validate, DeclarationPath, Diagnostic, DiagnosticKind}; diff --git a/packages/rs-dash-sdk-contract/src/validate/capabilities.rs b/packages/rs-dash-sdk-contract/src/validate/capabilities.rs new file mode 100644 index 00000000000..6b995752d81 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/validate/capabilities.rs @@ -0,0 +1,88 @@ +//! Capability checks: explicit requirements, derived requirements and the +//! interface-disabled rejection. + +use alloc::vec::Vec; + +use crate::declare::{ + CapabilityRequirement, CapabilityStatus, ContractDeclaration, ReceiptPolicy, RuleKind, Store, + WritePolicy, +}; +use crate::manifest::{ + CapabilityEntry, CapabilityTable, CollectionManifest, MethodTable, ModuleTable, RuleManifest, + TypedCollectionManifest, +}; +use crate::validate::diagnostic::{DeclarationPath, Diagnostic, DiagnosticKind}; + +pub(super) fn validate_capabilities( + declaration: &ContractDeclaration, + modules: &ModuleTable, + collections: &[CollectionManifest], + typed_collections: &[TypedCollectionManifest], + methods: &MethodTable, + rules: &[RuleManifest], + diagnostics: &mut Vec, +) -> CapabilityTable { + let mut requirements: Vec = Vec::new(); + + for requirement in &declaration.capabilities { + if !requirement.is_explicit() { + diagnostics.push(Diagnostic::new( + DeclarationPath::Capability(*requirement), + DiagnosticKind::CapabilityNotDeclarable { + requirement: *requirement, + }, + )); + continue; + } + requirements.push(*requirement); + } + + for collection in collections { + if collection.write == WritePolicy::Contract { + requirements.push(CapabilityRequirement::ContractWrites); + } + if collection.store == Store::Private { + requirements.push(CapabilityRequirement::PrivateStore); + } + } + for typed in typed_collections { + requirements.push(CapabilityRequirement::TypedCollections(typed.kind)); + } + for rule in rules { + requirements.push(match rule.kind { + RuleKind::NativeGuard(_) => CapabilityRequirement::NativeGuards, + RuleKind::WasmPredicate { .. } => CapabilityRequirement::WasmPredicates, + }); + } + if !methods.entries.is_empty() { + requirements.push(CapabilityRequirement::Entries); + } + if modules.modules.len() > 1 { + requirements.push(CapabilityRequirement::Modules); + } + if declaration.receipts == ReceiptPolicy::Stored { + requirements.push(CapabilityRequirement::StoredReceipts); + } + + requirements.sort(); + requirements.dedup(); + + let entries: Vec = requirements + .into_iter() + .map(|requirement| { + let status = requirement.status(); + if status == CapabilityStatus::InterfaceDisabled { + diagnostics.push(Diagnostic::new( + DeclarationPath::Capability(requirement), + DiagnosticKind::CapabilityInterfaceDisabled { requirement }, + )); + } + CapabilityEntry { + requirement, + status, + } + }) + .collect(); + + CapabilityTable { entries } +} diff --git a/packages/rs-dash-sdk-contract/src/validate/collections.rs b/packages/rs-dash-sdk-contract/src/validate/collections.rs new file mode 100644 index 00000000000..135dee632cb --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/validate/collections.rs @@ -0,0 +1,846 @@ +//! Collection checks: identity, fields and positions, bounds, indexes and +//! their cross-references, sugar expansion, singleton rules, typed +//! collections. + +use alloc::string::ToString; +use alloc::vec::Vec; + +use crate::declare::{ + CollectionKind, CollectionSpec, ContractDeclaration, Countability, FieldSpec, FieldType, + IndexSpec, IntegerWidth, RankedCount, ReferenceTarget, TypedCollectionSpec, ValueType, +}; +use crate::identity::{CollectionName, PropertyPath}; +use crate::manifest::{CollectionManifest, IndexManifest, TypedCollectionManifest}; +use crate::validate::diagnostic::{DeclarationPath, Diagnostic, DiagnosticKind}; +use crate::validate::merge::dedupe; + +/// What the entry checks need to know about each validated collection. +pub(super) struct CollectionSummary { + /// The collection's identity. + pub(super) name: CollectionName, + /// Documents or singleton. + pub(super) kind: CollectionKind, + /// Whether documents may be replaced. + pub(super) mutable: bool, +} + +/// The validated collections, for the entry checks. +pub(super) type CollectionKinds = Vec; + +pub(super) fn validate_collections( + declaration: &ContractDeclaration, + diagnostics: &mut Vec, +) -> (Vec, CollectionKinds) { + let collections = dedupe( + &declaration.collections, + |spec| DeclarationPath::collection(&spec.name), + diagnostics, + ); + + let names: Vec<&CollectionSpec> = collections.iter().collect(); + let mut manifests: Vec = collections + .iter() + .map(|collection| validate_collection(collection, &names, diagnostics)) + .collect(); + manifests.sort_by(|a, b| a.name.cmp(&b.name)); + + let kinds = manifests + .iter() + .map(|manifest| CollectionSummary { + name: manifest.name.clone(), + kind: manifest.kind, + mutable: manifest.mutable, + }) + .collect(); + (manifests, kinds) +} + +fn validate_collection( + collection: &CollectionSpec, + all: &[&CollectionSpec], + diagnostics: &mut Vec, +) -> CollectionManifest { + let path = DeclarationPath::collection(&collection.name); + + if collection.schema_revision == 0 { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::InvalidSchemaRevision, + )); + } + + match collection.kind { + CollectionKind::Documents => { + if collection.document_id_field.is_none() { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::MissingDocumentIdField, + )); + } + } + CollectionKind::Singleton => { + if collection.document_id_field.is_some() { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::SingletonWithDocumentIdField, + )); + } + if !collection.indexes.is_empty() { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::SingletonWithIndexes, + )); + } + } + } + + let fields = validate_fields( + &collection.name, + "", + &collection.fields, + &collection.fields, + all, + diagnostics, + ); + + let mut requires: Vec = Vec::new(); + for property in &collection.requires { + if !property.is_system() { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::RequiredPropertyNotSystem { + property: property.to_string(), + }, + )); + } + if requires.contains(property) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::DuplicateRequiredProperty { + property: property.to_string(), + }, + )); + } else { + requires.push(property.clone()); + } + } + requires.sort(); + + let mut token_costs = collection.token_costs.clone(); + token_costs.sort_by_key(|cost| cost.action); + let mut seen_actions = Vec::new(); + for cost in &token_costs { + if seen_actions.contains(&cost.action) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::DuplicateTokenCostAction { + action: cost.action.to_string(), + }, + )); + } else { + seen_actions.push(cost.action); + } + } + + // Collection-level average sugar: `average = p` is `count` plus `sum = p`; + // `range_average` is `range_count` plus `range_sum`. The same conflict + // rules the native parser applies to `documentsAverageable`: an omitted + // option is promoted silently, an explicit `false` next to the sugar is a + // contradiction the author must resolve. + let mut count = collection.count.unwrap_or(false); + let mut range_count = collection.range_count.unwrap_or(false); + let mut sum = collection.sum.clone(); + let mut range_sum = collection.range_sum.unwrap_or(false); + if let Some(average) = &collection.average { + if let Some(existing) = &sum { + if existing != average { + diagnostics.push(conflicting_option( + &path, + "average", + "sum", + "both name the summed property, so they must agree", + )); + } + } + if collection.count == Some(false) { + diagnostics.push(conflicting_option( + &path, + "average", + "count", + "average implies count; remove the explicit `count = false`", + )); + } + count = true; + sum = Some(average.clone()); + if collection.range_average { + if collection.range_count == Some(false) { + diagnostics.push(conflicting_option( + &path, + "range_average", + "range_count", + "range_average implies range_count; remove the explicit `range_count = false`", + )); + } + if collection.range_sum == Some(false) { + diagnostics.push(conflicting_option( + &path, + "range_average", + "range_sum", + "range_average implies range_sum; remove the explicit `range_sum = false`", + )); + } + range_count = true; + range_sum = true; + } + } else if collection.range_average { + diagnostics.push(conflicting_option( + &path, + "range_average", + "average", + "range_average needs average to name the property", + )); + } + if let Some(summed) = &sum { + if !has_top_level_property(&fields, summed.as_str()) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::SummablePropertyUnknown { + property: summed.to_string(), + }, + )); + } + } + + let indexes = dedupe( + &collection.indexes, + |spec| DeclarationPath::index(&collection.name, &spec.name), + diagnostics, + ); + let mut index_manifests: Vec = indexes + .iter() + .map(|index| validate_index(collection, &fields, index, diagnostics)) + .collect(); + index_manifests.sort_by(|a, b| a.name.cmp(&b.name)); + + CollectionManifest { + name: collection.name.clone(), + kind: collection.kind, + schema_revision: collection.schema_revision, + write: collection.write, + mutable: collection.mutable, + deletable: collection.deletable, + keep_history: collection.keep_history, + keep_transfer_history: collection.keep_transfer_history, + keep_purchase_history: collection.keep_purchase_history, + keep_pricing_history: collection.keep_pricing_history, + transferable: collection.transferable, + trade: collection.trade, + security_level: collection.security_level, + encryption_key: collection.encryption_key, + decryption_key: collection.decryption_key, + count, + range_count, + sum, + range_sum, + index_only: collection.index_only, + requires, + token_costs, + store: collection.store, + fields, + indexes: index_manifests, + } +} + +fn conflicting_option( + path: &DeclarationPath, + first: &str, + second: &str, + reason: &str, +) -> Diagnostic { + Diagnostic::new( + path.clone(), + DiagnosticKind::ConflictingOption { + options: alloc::vec![first.to_string(), second.to_string()], + reason: reason.to_string(), + }, + ) +} + +fn join_path(prefix: &str, name: &str) -> alloc::string::String { + if prefix.is_empty() { + name.to_string() + } else { + let mut path = prefix.to_string(); + path.push('.'); + path.push_str(name); + path + } +} + +/// Validates one nesting level of fields and returns them sorted by position. +/// `root` is the collection's top-level fields: reference paths resolve from +/// the document root, the way native registration resolves them through the +/// flattened properties, whatever the nesting level of the referring field. +fn validate_fields( + collection: &CollectionName, + prefix: &str, + fields: &[FieldSpec], + root: &[FieldSpec], + all: &[&CollectionSpec], + diagnostics: &mut Vec, +) -> Vec { + let mut seen_names: Vec<&str> = Vec::new(); + let mut positions: Vec = Vec::new(); + let mut sorted = Vec::new(); + for field in fields { + let dotted = join_path(prefix, field.name.as_str()); + let path = DeclarationPath::field(collection, &dotted); + if seen_names.contains(&field.name.as_str()) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::DuplicateProperty, + )); + } else { + seen_names.push(field.name.as_str()); + } + if positions.contains(&field.position) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::DuplicatePosition { + position: field.position, + }, + )); + } + positions.push(field.position); + + let ty = validate_field_type(collection, &dotted, root, &field.ty, all, diagnostics); + let mut validated = field.clone(); + validated.ty = ty; + sorted.push(validated); + } + + let mut unique_positions = positions.clone(); + unique_positions.sort_unstable(); + unique_positions.dedup(); + let contiguous = unique_positions + .iter() + .enumerate() + .all(|(i, &position)| position as usize == i); + if !contiguous { + diagnostics.push(Diagnostic::new( + if prefix.is_empty() { + DeclarationPath::collection(collection) + } else { + DeclarationPath::field(collection, prefix) + }, + DiagnosticKind::NonContiguousPositions { + positions: unique_positions, + }, + )); + } + + sorted.sort_by(|a, b| a.position.cmp(&b.position).then(a.name.cmp(&b.name))); + sorted +} + +fn validate_field_type( + collection: &CollectionName, + dotted: &str, + root: &[FieldSpec], + ty: &FieldType, + all: &[&CollectionSpec], + diagnostics: &mut Vec, +) -> FieldType { + let path = DeclarationPath::field(collection, dotted); + match ty { + FieldType::Integer { width, bounds } => { + check_integer_bounds(&path, *width, bounds.min, bounds.max, diagnostics); + ty.clone() + } + FieldType::String { + max_chars: None, .. + } + | FieldType::Bytes { max_len: None, .. } => { + diagnostics.push(Diagnostic::new(path, DiagnosticKind::UnboundedField)); + ty.clone() + } + FieldType::String { + min_chars: Some(min), + max_chars: Some(max), + } + | FieldType::Bytes { + min_len: Some(min), + max_len: Some(max), + } if min > max => { + diagnostics.push(Diagnostic::new( + path, + DiagnosticKind::LengthBoundsInverted { + min: *min, + max: *max, + }, + )); + ty.clone() + } + FieldType::Reference(target) => validate_reference(&path, target, root, all, diagnostics), + FieldType::Object(nested) => FieldType::Object(validate_fields( + collection, + dotted, + nested, + root, + all, + diagnostics, + )), + FieldType::Enum(values) => { + // A closed set: the manifest stores it sorted so declaration order + // never leaks in. Repeated values stay for native validation to + // reject. + let mut values = values.clone(); + values.sort(); + FieldType::Enum(values) + } + FieldType::Bool + | FieldType::F64 + | FieldType::String { .. } + | FieldType::Bytes { .. } + | FieldType::Identifier => ty.clone(), + } +} + +pub(super) fn check_integer_bounds( + path: &DeclarationPath, + width: IntegerWidth, + min: Option, + max: Option, + diagnostics: &mut Vec, +) { + let inside = |bound: i128| bound >= width.min_value() && bound <= width.max_value(); + let mut outside = false; + for bound in [min, max].into_iter().flatten() { + if !inside(bound) { + outside = true; + } else if bound > i64::MAX as i128 { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::IntegerBoundNotNativelyRepresentable { bound }, + )); + } + } + if let (Some(min), Some(max)) = (min, max) { + if min > max { + outside = true; + } + } + if outside { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::IntegerBoundsOutsideType { + width: width.rust_name().to_string(), + }, + )); + } +} + +/// Validates a reference target against the declaring collection's root +/// fields and returns the type with its agreement in canonical order. +fn validate_reference( + path: &DeclarationPath, + target: &ReferenceTarget, + root: &[FieldSpec], + all: &[&CollectionSpec], + diagnostics: &mut Vec, +) -> FieldType { + match target { + ReferenceTarget::Identity | ReferenceTarget::Contract | ReferenceTarget::Token => { + FieldType::Reference(target.clone()) + } + ReferenceTarget::PermanentDocument { + contract, + document_type, + agreement, + } => { + // Only same-contract references can be checked here; another + // contract's types are validated natively at registration. + let referenced = all + .iter() + .find(|candidate| candidate.name == *document_type); + if contract.is_none() { + match referenced { + None => diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::ReferenceCollectionUnknown { + collection: document_type.to_string(), + }, + )), + Some(referenced) => { + for (_, referenced_property) in agreement { + if !has_property_path(&referenced.fields, referenced_property) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::ReferencePropertyUnknown { + property: referenced_property.to_string(), + }, + )); + } + } + } + } + } + let mut seen: Vec<&PropertyPath> = Vec::new(); + for (referring_property, _) in agreement { + if !has_property_path(root, referring_property) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::ReferencePropertyUnknown { + property: referring_property.to_string(), + }, + )); + } + if seen.contains(&referring_property) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::DuplicateAgreementProperty { + property: referring_property.to_string(), + }, + )); + } else { + seen.push(referring_property); + } + } + // The agreement is a map keyed by the referring property; the + // manifest stores it sorted so declaration order never leaks in. + let mut agreement = agreement.clone(); + agreement.sort(); + FieldType::Reference(ReferenceTarget::PermanentDocument { + contract: *contract, + document_type: document_type.clone(), + agreement, + }) + } + ReferenceTarget::IdentityPublicKey { key_id_field } => { + if !has_property_path(root, key_id_field) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::ReferencePropertyUnknown { + property: key_id_field.to_string(), + }, + )); + } + FieldType::Reference(target.clone()) + } + } +} + +fn has_top_level_property(fields: &[FieldSpec], name: &str) -> bool { + fields.iter().any(|field| field.name.as_str() == name) +} + +/// Resolves a dotted path through nested objects. +pub(super) fn has_property_path(fields: &[FieldSpec], path: &PropertyPath) -> bool { + if path.is_system() { + return true; + } + let mut current = fields; + let mut segments = path.segments().peekable(); + while let Some(segment) = segments.next() { + let Some(field) = current.iter().find(|field| field.name.as_str() == segment) else { + return false; + }; + if segments.peek().is_none() { + return true; + } + match &field.ty { + FieldType::Object(nested) => current = nested, + _ => return false, + } + } + false +} + +fn validate_index( + collection: &CollectionSpec, + fields: &[FieldSpec], + index: &IndexSpec, + diagnostics: &mut Vec, +) -> IndexManifest { + let path = DeclarationPath::index(&collection.name, &index.name); + + for property in &index.properties { + if !has_property_path(fields, property) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::IndexPropertyUnknown { + property: property.to_string(), + }, + )); + } + } + + // Index-level average sugar, expanded the way the native parser expands + // `averageable` / `rangeAverageable`: an omitted option is promoted, an + // explicit non-countable or `false` next to the sugar is a contradiction. + let mut count = index.count.unwrap_or_default(); + let mut range_count = index.range_count.unwrap_or(false); + let mut sum = index.sum.clone(); + let mut range_sum = index.range_sum.unwrap_or(false); + if let Some(average) = &index.average { + if let Some(existing) = &sum { + if existing != average { + diagnostics.push(conflicting_option( + &path, + "average", + "sum", + "both name the summed property, so they must agree", + )); + } + } + match index.count { + None => count = Countability::Countable, + Some(explicit) if !explicit.is_countable() => { + diagnostics.push(conflicting_option( + &path, + "average", + "count", + "average implies a countable index; remove the explicit not-countable setting", + )); + } + Some(_) => {} + } + sum = Some(average.clone()); + if index.range_average { + if index.range_count == Some(false) { + diagnostics.push(conflicting_option( + &path, + "range_average", + "range_count", + "range_average implies range_count; remove the explicit `range_count = false`", + )); + } + if index.range_sum == Some(false) { + diagnostics.push(conflicting_option( + &path, + "range_average", + "range_sum", + "range_average implies range_sum; remove the explicit `range_sum = false`", + )); + } + range_count = true; + range_sum = true; + } + } else if index.range_average { + diagnostics.push(conflicting_option( + &path, + "range_average", + "average", + "range_average needs average to name the property", + )); + } + if let Some(summed) = &sum { + if !has_top_level_property(fields, summed.as_str()) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::SummablePropertyUnknown { + property: summed.to_string(), + }, + )); + } + } + + let mut contested = index.contested.clone(); + if let Some(contested) = contested.as_mut() { + let mut seen: Vec<&PropertyPath> = Vec::new(); + for (property, _) in &contested.field_matches { + if !index.properties.contains(property) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::ContestedFieldNotIndexed { + property: property.to_string(), + }, + )); + } + if seen.contains(&property) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::DuplicateContestedField { + property: property.to_string(), + }, + )); + } else { + seen.push(property); + } + } + contested.field_matches.sort(); + } + + if let RankedCount::At(levels) = &index.ranked.count { + let mut seen: Vec<&PropertyPath> = Vec::new(); + for level in levels { + if !index.properties.contains(level) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::RankedLevelNotIndexed { + property: level.to_string(), + }, + )); + } + if seen.contains(&level) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::DuplicateRankedLevel { + property: level.to_string(), + }, + )); + } else { + seen.push(level); + } + } + } + + if let Some(time_range) = &index.time_range { + if index.properties.first() != Some(&time_range.on) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::TimeRangeSourceNotFirst { + property: time_range.on.to_string(), + }, + )); + } + if time_range.on.is_system() && !collection.requires.contains(&time_range.on) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::TimeRangeSourceNotRequired { + property: time_range.on.to_string(), + }, + )); + } + } + + if let Some(options) = &index.index_only { + if !collection.index_only { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::IndexOnlyOptionOnStoredCollection, + )); + } + if let Some(terminal) = &options.terminal { + let is_owner = terminal.as_str() == "$ownerId"; + if !is_owner && (terminal.is_system() || !has_property_path(fields, terminal)) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::TerminalNotAProperty { + property: terminal.to_string(), + }, + )); + } + } + } + + let mut ranked = index.ranked.clone(); + if let RankedCount::At(levels) = &mut ranked.count { + levels.sort(); + } + + IndexManifest { + name: index.name.clone(), + properties: index.properties.clone(), + unique: index.unique, + null_searchable: index.null_searchable, + contested, + count, + range_count, + sum, + range_sum, + ranked, + time_range: index.time_range.clone(), + index_only: index.index_only.clone(), + } +} + +pub(super) fn validate_typed_collections( + declaration: &ContractDeclaration, + collections: &[CollectionManifest], + diagnostics: &mut Vec, +) -> Vec { + let typed = dedupe( + &declaration.typed_collections, + |spec| DeclarationPath::typed_collection(&spec.id), + diagnostics, + ); + let mut manifests: Vec = typed + .iter() + .map(|spec: &TypedCollectionSpec| { + let path = DeclarationPath::typed_collection(&spec.id); + if collections + .iter() + .any(|collection| collection.name == spec.id) + { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::DuplicateCollection, + )); + } + check_value_type(&path.member("key"), &spec.key, diagnostics); + check_value_type(&path.member("element"), &spec.element, diagnostics); + TypedCollectionManifest { + id: spec.id.clone(), + kind: spec.kind, + key: spec.key.clone(), + element: spec.element.clone(), + max_elements: spec.max_elements, + } + }) + .collect(); + manifests.sort_by(|a, b| a.id.cmp(&b.id)); + manifests +} + +/// Every string, byte array and list in a wire type declares a maximum. +/// Diagnostics inside a struct or list point at the member through +/// [`DeclarationPath::member`], so two unbounded members are distinguishable. +pub(super) fn check_value_type( + path: &DeclarationPath, + ty: &ValueType, + diagnostics: &mut Vec, +) { + match ty { + ValueType::String { max_chars: None } | ValueType::Bytes { max_len: None } => { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::UnboundedField, + )); + } + ValueType::List { max_len, item } => { + if max_len.is_none() { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::UnboundedField, + )); + } + check_value_type(&path.member("[]"), item, diagnostics); + } + ValueType::Option(inner) => check_value_type(path, inner, diagnostics), + ValueType::Struct(members) => { + let mut seen: Vec<&str> = Vec::new(); + for (name, member) in members { + if seen.contains(&name.as_str()) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::DuplicateStructMember { + member: name.clone(), + }, + )); + } else { + seen.push(name); + } + check_value_type(&path.member(name), member, diagnostics); + } + } + ValueType::Unit + | ValueType::Bool + | ValueType::Integer(_) + | ValueType::F64 + | ValueType::String { .. } + | ValueType::Bytes { .. } + | ValueType::Identifier + | ValueType::DocumentId => {} + } +} diff --git a/packages/rs-dash-sdk-contract/src/validate/diagnostic.rs b/packages/rs-dash-sdk-contract/src/validate/diagnostic.rs new file mode 100644 index 00000000000..6bc6531f59d --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/validate/diagnostic.rs @@ -0,0 +1,912 @@ +//! Diagnostics: typed, append-only, with stable codes. + +use alloc::boxed::Box; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use core::fmt; + +use crate::declare::{CapabilityRequirement, DeclarationOrigin}; +use crate::identity::InvalidName; + +/// Where in the declaration a diagnostic points. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DeclarationPath { + /// The contract as a whole. + Contract, + /// An attribute, optionally one of its options. Nested option lists use + /// dotted attribute names (`index.time_range`). + Attribute { + /// The attribute name. + attribute: String, + /// The option name, when the diagnostic is about one option. + option: Option, + }, + /// A module. + Module(String), + /// An interface. + Interface(String), + /// A document collection or singleton. + Collection(String), + /// A stored field, by dotted path. + Field { + /// The collection. + collection: String, + /// The dotted field path. + field: String, + }, + /// An index. + Index { + /// The collection. + collection: String, + /// The index name. + index: String, + }, + /// A typed specialized collection. + TypedCollection(String), + /// An entry. + Entry(String), + /// An entry parameter or its return value. + EntryParam { + /// The entry. + entry: String, + /// The parameter name, or `return`. + param: String, + }, + /// A rule. + Rule { + /// The collection. + collection: String, + /// The rule name. + rule: String, + }, + /// A capability requirement. + Capability(CapabilityRequirement), + /// An interface function parameter or its return value. + InterfaceParam { + /// The interface. + interface: String, + /// The function. + function: String, + /// The parameter name, or `return`. + param: String, + }, + /// A member inside a wire value: struct members by name, list items as + /// `[]`, joined with `.` (`profile.avatars.[]`). + Member { + /// The parameter, return value or typed collection slot holding the + /// value. + parent: Box, + /// The dotted member path. + member: String, + }, +} + +impl DeclarationPath { + /// The path of a member inside this wire value. + pub fn member(&self, segment: &str) -> Self { + match self { + DeclarationPath::Member { parent, member } => DeclarationPath::Member { + parent: parent.clone(), + member: alloc::format!("{member}.{segment}"), + }, + other => DeclarationPath::Member { + parent: Box::new(other.clone()), + member: segment.to_string(), + }, + } + } + + /// An attribute path. + pub fn attribute(attribute: &str, option: Option<&str>) -> Self { + DeclarationPath::Attribute { + attribute: attribute.to_string(), + option: option.map(|option| option.to_string()), + } + } + + /// A collection path. + pub fn collection(name: impl AsRef) -> Self { + DeclarationPath::Collection(name.as_ref().to_string()) + } + + /// A field path. + pub fn field(collection: impl AsRef, field: impl AsRef) -> Self { + DeclarationPath::Field { + collection: collection.as_ref().to_string(), + field: field.as_ref().to_string(), + } + } + + /// An index path. + pub fn index(collection: impl AsRef, index: impl AsRef) -> Self { + DeclarationPath::Index { + collection: collection.as_ref().to_string(), + index: index.as_ref().to_string(), + } + } + + /// An entry path. + pub fn entry(name: impl AsRef) -> Self { + DeclarationPath::Entry(name.as_ref().to_string()) + } + + /// An entry parameter path. + pub fn entry_param(entry: impl AsRef, param: impl AsRef) -> Self { + DeclarationPath::EntryParam { + entry: entry.as_ref().to_string(), + param: param.as_ref().to_string(), + } + } + + /// A rule path. + pub fn rule(collection: impl AsRef, rule: impl AsRef) -> Self { + DeclarationPath::Rule { + collection: collection.as_ref().to_string(), + rule: rule.as_ref().to_string(), + } + } + + /// A module path. + pub fn module(name: impl AsRef) -> Self { + DeclarationPath::Module(name.as_ref().to_string()) + } + + /// An interface path. + pub fn interface(name: impl AsRef) -> Self { + DeclarationPath::Interface(name.as_ref().to_string()) + } + + /// A typed collection path. + pub fn typed_collection(name: impl AsRef) -> Self { + DeclarationPath::TypedCollection(name.as_ref().to_string()) + } + + /// An interface function parameter path. + pub fn interface_param( + interface: impl AsRef, + function: impl AsRef, + param: impl AsRef, + ) -> Self { + DeclarationPath::InterfaceParam { + interface: interface.as_ref().to_string(), + function: function.as_ref().to_string(), + param: param.as_ref().to_string(), + } + } +} + +impl fmt::Display for DeclarationPath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DeclarationPath::Contract => f.write_str("contract"), + DeclarationPath::Attribute { attribute, option } => match option { + Some(option) => write!(f, "attribute {attribute}, option {option}"), + None => write!(f, "attribute {attribute}"), + }, + DeclarationPath::Module(name) => write!(f, "module {name}"), + DeclarationPath::Interface(name) => write!(f, "interface {name}"), + DeclarationPath::Collection(name) => write!(f, "collection {name}"), + DeclarationPath::Field { collection, field } => { + write!(f, "collection {collection}, field {field}") + } + DeclarationPath::Index { collection, index } => { + write!(f, "collection {collection}, index {index}") + } + DeclarationPath::TypedCollection(name) => write!(f, "typed collection {name}"), + DeclarationPath::Entry(name) => write!(f, "entry {name}"), + DeclarationPath::EntryParam { entry, param } => { + write!(f, "entry {entry}, parameter {param}") + } + DeclarationPath::Rule { collection, rule } => { + write!(f, "collection {collection}, rule {rule}") + } + DeclarationPath::Capability(requirement) => write!(f, "capability {requirement}"), + DeclarationPath::InterfaceParam { + interface, + function, + param, + } => write!( + f, + "interface {interface}, function {function}, parameter {param}" + ), + DeclarationPath::Member { parent, member } => write!(f, "{parent}, member {member}"), + } + } +} + +/// What went wrong. +/// +/// Variants are appended, never reordered or removed: their position is +/// their stable code (`DSC0001` onwards, provisional). The rule each one +/// enforces is on the variant. +// @append_only +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DiagnosticKind { + /// The attribute is not in the grammar. + UnknownAttribute { + /// The attribute as written. + attribute: String, + }, + /// The option is not in the attribute's grammar. + UnknownOption { + /// The attribute. + attribute: String, + /// The option as written. + option: String, + }, + /// The option's value has the wrong shape or is outside its closed set. + InvalidOptionValue { + /// The attribute. + attribute: String, + /// The option. + option: String, + /// What was expected. + reason: String, + }, + /// A required option is absent. + MissingOption { + /// The attribute. + attribute: String, + /// The option. + option: String, + }, + /// An option is given twice. + DuplicateOption { + /// The attribute. + attribute: String, + /// The option. + option: String, + }, + /// Exactly one option of a group must be present (`guard` or + /// `predicate` on a rule). + ExactlyOneOptionRequired { + /// The attribute. + attribute: String, + /// The group. + options: Vec, + /// How many were given. + given: usize, + }, + /// Two options describe the same thing differently (`average` naming a + /// different property than `sum`, `range_average` without `average`). + ConflictingOption { + /// The two options. + options: Vec, + /// Why they conflict. + reason: String, + }, + /// An attribute and a builder declare the same item differently. The + /// origins are reported attribute first and builder second whatever the + /// declaration order. + ConflictingDeclaration { + /// What kind of item. + what: String, + /// The attribute origin. + first: DeclarationOrigin, + /// The builder origin. + second: DeclarationOrigin, + }, + /// Two declarations of the same origin use one collection name. + DuplicateCollection, + /// Two declarations of the same origin use one index name in one + /// collection. + DuplicateIndex, + /// Two declarations of the same origin use one method name. + DuplicateMethod, + /// Two entries produce the same export symbol. + DuplicateExportSymbol { + /// The symbol. + export: String, + }, + /// Two declarations of the same origin use one rule name in one + /// collection. + DuplicateRule, + /// Two declarations of the same origin use one module name. + DuplicateModule, + /// Two declarations of the same origin use one interface name. + DuplicateInterface, + /// Two fields at one nesting level share a name. + DuplicateProperty, + /// Two fields at one nesting level share a position. + DuplicatePosition { + /// The position. + position: u32, + }, + /// Positions at one nesting level are not `0..n`. + NonContiguousPositions { + /// The positions as declared, sorted. + positions: Vec, + }, + /// A token cost is declared twice for one action. + DuplicateTokenCostAction { + /// The action. + action: String, + }, + /// A name fails its identity grammar. + InvalidName(InvalidName), + /// A string, byte array or list declares no maximum. + UnboundedField, + /// Integer bounds fall outside the declared Rust width, or `min > max`. + IntegerBoundsOutsideType { + /// The Rust width. + width: String, + }, + /// A bound cannot be expressed in the native schema, which reads bounds + /// as signed 64-bit integers; omit the bound to get the full width. + IntegerBoundNotNativelyRepresentable { + /// The bound. + bound: i128, + }, + /// An index names a property the collection does not declare. + IndexPropertyUnknown { + /// The property path. + property: String, + }, + /// A sum names a property the collection does not declare. + SummablePropertyUnknown { + /// The property. + property: String, + }, + /// A contested field match names a property the index does not cover. + ContestedFieldNotIndexed { + /// The property path. + property: String, + }, + /// A time range buckets a property that is not the index's first. + TimeRangeSourceNotFirst { + /// The property path. + property: String, + }, + /// An index-only terminal is neither `$ownerId` nor a declared property. + TerminalNotAProperty { + /// The property path. + property: String, + }, + /// A ranked level names a property the index does not cover. + RankedLevelNotIndexed { + /// The property path. + property: String, + }, + /// `terminal`, `preallocated` or `skip_if_absent` on a collection that is + /// not index-only. + IndexOnlyOptionOnStoredCollection, + /// A singleton declares indexes. + SingletonWithIndexes, + /// A singleton marks a field `#[document_id]`. + SingletonWithDocumentIdField, + /// A persistent struct has no `#[document_id]` field. + MissingDocumentIdField, + /// The schema revision is 0. + InvalidSchemaRevision, + /// A reference names a collection this contract does not declare. + ReferenceCollectionUnknown { + /// The collection. + collection: String, + }, + /// A reference names a property that does not exist. + ReferencePropertyUnknown { + /// The property path. + property: String, + }, + /// An entry receiver names a collection this contract does not declare. + ReceiverCollectionUnknown { + /// The collection. + collection: String, + }, + /// A `&mut self` entry on a collection whose documents cannot be + /// replaced: there is no write for the wrapper to stage. + MutableReceiverOnImmutableCollection { + /// The collection. + collection: String, + }, + /// A read-only entry with a `&mut self` receiver. + ReadOnlyEntryWithMutableReceiver, + /// Two parameters of one entry or interface function share a name. + DuplicateParameter { + /// The parameter. + param: String, + }, + /// An entry binds to a module this contract does not declare. + EntryModuleUnknown { + /// The module. + module: String, + }, + /// An entry names no module and the contract declares several. + EntryModuleRequired, + /// An interface's provider is not a declared module. + InterfaceProviderUnknown { + /// The module. + module: String, + }, + /// A module uses an interface no declaration provides. + UsedInterfaceUnknown { + /// The interface. + interface: String, + }, + /// A module uses an interface it provides itself. + ModuleSelfImport { + /// The interface. + interface: String, + }, + /// The module import graph has a cycle. + ModuleGraphCycle { + /// The modules on the cycle, in import order. + modules: Vec, + }, + /// Two functions of one interface share a name. + DuplicateInterfaceFunction { + /// The function. + function: String, + }, + /// A predicate names a module this contract does not declare. + PredicateModuleUnknown { + /// The module. + module: String, + }, + /// A rule guards a collection this contract does not declare. + RuleCollectionUnknown { + /// The collection. + collection: String, + }, + /// A rule names no action. + RuleWithoutActions, + /// A guard reads a property the guarded collection does not declare. + GuardFieldUnknown { + /// The property path. + property: String, + }, + /// A capability whose interface is disabled is required (the private + /// document store). + CapabilityInterfaceDisabled { + /// The requirement. + requirement: CapabilityRequirement, + }, + /// A derived capability is listed as an explicit requirement. + CapabilityNotDeclarable { + /// The requirement. + requirement: CapabilityRequirement, + }, + /// Reserved and never produced: the model has no raw path, raw element or + /// database handle grammar, so there is nothing to reject. The variant + /// documents that absence. + RawPathDeclaration, + /// A string or byte array declares a minimum above its maximum. + LengthBoundsInverted { + /// The minimum. + min: u16, + /// The maximum. + max: u16, + }, + /// Two members of one wire struct share a name. + DuplicateStructMember { + /// The member. + member: String, + }, + /// A contested index names one field match property twice. + DuplicateContestedField { + /// The property path. + property: String, + }, + /// A reference agreement names one referring property twice. + DuplicateAgreementProperty { + /// The property path. + property: String, + }, + /// A ranked count names one index level twice. + DuplicateRankedLevel { + /// The property path. + property: String, + }, + /// A required system property is not a system property; user fields are + /// required on the field itself. + RequiredPropertyNotSystem { + /// The property path. + property: String, + }, + /// A collection lists one required system property twice. + DuplicateRequiredProperty { + /// The property path. + property: String, + }, + /// A time range buckets a system timestamp the collection does not + /// require; the timestamp is populated only when required. + TimeRangeSourceNotRequired { + /// The property path. + property: String, + }, +} + +impl DiagnosticKind { + fn code_and_name(&self) -> (&'static str, &'static str) { + match self { + DiagnosticKind::UnknownAttribute { .. } => ("DSC0001", "UnknownAttribute"), + DiagnosticKind::UnknownOption { .. } => ("DSC0002", "UnknownOption"), + DiagnosticKind::InvalidOptionValue { .. } => ("DSC0003", "InvalidOptionValue"), + DiagnosticKind::MissingOption { .. } => ("DSC0004", "MissingOption"), + DiagnosticKind::DuplicateOption { .. } => ("DSC0005", "DuplicateOption"), + DiagnosticKind::ExactlyOneOptionRequired { .. } => { + ("DSC0006", "ExactlyOneOptionRequired") + } + DiagnosticKind::ConflictingOption { .. } => ("DSC0007", "ConflictingOption"), + DiagnosticKind::ConflictingDeclaration { .. } => ("DSC0008", "ConflictingDeclaration"), + DiagnosticKind::DuplicateCollection => ("DSC0009", "DuplicateCollection"), + DiagnosticKind::DuplicateIndex => ("DSC0010", "DuplicateIndex"), + DiagnosticKind::DuplicateMethod => ("DSC0011", "DuplicateMethod"), + DiagnosticKind::DuplicateExportSymbol { .. } => ("DSC0012", "DuplicateExportSymbol"), + DiagnosticKind::DuplicateRule => ("DSC0013", "DuplicateRule"), + DiagnosticKind::DuplicateModule => ("DSC0014", "DuplicateModule"), + DiagnosticKind::DuplicateInterface => ("DSC0015", "DuplicateInterface"), + DiagnosticKind::DuplicateProperty => ("DSC0016", "DuplicateProperty"), + DiagnosticKind::DuplicatePosition { .. } => ("DSC0017", "DuplicatePosition"), + DiagnosticKind::NonContiguousPositions { .. } => ("DSC0018", "NonContiguousPositions"), + DiagnosticKind::DuplicateTokenCostAction { .. } => { + ("DSC0019", "DuplicateTokenCostAction") + } + DiagnosticKind::InvalidName(_) => ("DSC0020", "InvalidName"), + DiagnosticKind::UnboundedField => ("DSC0021", "UnboundedField"), + DiagnosticKind::IntegerBoundsOutsideType { .. } => { + ("DSC0022", "IntegerBoundsOutsideType") + } + DiagnosticKind::IntegerBoundNotNativelyRepresentable { .. } => { + ("DSC0023", "IntegerBoundNotNativelyRepresentable") + } + DiagnosticKind::IndexPropertyUnknown { .. } => ("DSC0024", "IndexPropertyUnknown"), + DiagnosticKind::SummablePropertyUnknown { .. } => { + ("DSC0025", "SummablePropertyUnknown") + } + DiagnosticKind::ContestedFieldNotIndexed { .. } => { + ("DSC0026", "ContestedFieldNotIndexed") + } + DiagnosticKind::TimeRangeSourceNotFirst { .. } => { + ("DSC0027", "TimeRangeSourceNotFirst") + } + DiagnosticKind::TerminalNotAProperty { .. } => ("DSC0028", "TerminalNotAProperty"), + DiagnosticKind::RankedLevelNotIndexed { .. } => ("DSC0029", "RankedLevelNotIndexed"), + DiagnosticKind::IndexOnlyOptionOnStoredCollection => { + ("DSC0030", "IndexOnlyOptionOnStoredCollection") + } + DiagnosticKind::SingletonWithIndexes => ("DSC0031", "SingletonWithIndexes"), + DiagnosticKind::SingletonWithDocumentIdField => { + ("DSC0032", "SingletonWithDocumentIdField") + } + DiagnosticKind::MissingDocumentIdField => ("DSC0033", "MissingDocumentIdField"), + DiagnosticKind::InvalidSchemaRevision => ("DSC0034", "InvalidSchemaRevision"), + DiagnosticKind::ReferenceCollectionUnknown { .. } => { + ("DSC0035", "ReferenceCollectionUnknown") + } + DiagnosticKind::ReferencePropertyUnknown { .. } => { + ("DSC0036", "ReferencePropertyUnknown") + } + DiagnosticKind::ReceiverCollectionUnknown { .. } => { + ("DSC0037", "ReceiverCollectionUnknown") + } + DiagnosticKind::MutableReceiverOnImmutableCollection { .. } => { + ("DSC0038", "MutableReceiverOnImmutableCollection") + } + DiagnosticKind::ReadOnlyEntryWithMutableReceiver => { + ("DSC0039", "ReadOnlyEntryWithMutableReceiver") + } + DiagnosticKind::DuplicateParameter { .. } => ("DSC0040", "DuplicateParameter"), + DiagnosticKind::EntryModuleUnknown { .. } => ("DSC0041", "EntryModuleUnknown"), + DiagnosticKind::EntryModuleRequired => ("DSC0042", "EntryModuleRequired"), + DiagnosticKind::InterfaceProviderUnknown { .. } => { + ("DSC0043", "InterfaceProviderUnknown") + } + DiagnosticKind::UsedInterfaceUnknown { .. } => ("DSC0044", "UsedInterfaceUnknown"), + DiagnosticKind::ModuleSelfImport { .. } => ("DSC0045", "ModuleSelfImport"), + DiagnosticKind::ModuleGraphCycle { .. } => ("DSC0046", "ModuleGraphCycle"), + DiagnosticKind::DuplicateInterfaceFunction { .. } => { + ("DSC0047", "DuplicateInterfaceFunction") + } + DiagnosticKind::PredicateModuleUnknown { .. } => ("DSC0048", "PredicateModuleUnknown"), + DiagnosticKind::RuleCollectionUnknown { .. } => ("DSC0049", "RuleCollectionUnknown"), + DiagnosticKind::RuleWithoutActions => ("DSC0050", "RuleWithoutActions"), + DiagnosticKind::GuardFieldUnknown { .. } => ("DSC0051", "GuardFieldUnknown"), + DiagnosticKind::CapabilityInterfaceDisabled { .. } => { + ("DSC0052", "CapabilityInterfaceDisabled") + } + DiagnosticKind::CapabilityNotDeclarable { .. } => { + ("DSC0053", "CapabilityNotDeclarable") + } + DiagnosticKind::RawPathDeclaration => ("DSC0054", "RawPathDeclaration"), + DiagnosticKind::LengthBoundsInverted { .. } => ("DSC0055", "LengthBoundsInverted"), + DiagnosticKind::DuplicateStructMember { .. } => ("DSC0056", "DuplicateStructMember"), + DiagnosticKind::DuplicateContestedField { .. } => { + ("DSC0057", "DuplicateContestedField") + } + DiagnosticKind::DuplicateAgreementProperty { .. } => { + ("DSC0058", "DuplicateAgreementProperty") + } + DiagnosticKind::DuplicateRankedLevel { .. } => ("DSC0059", "DuplicateRankedLevel"), + DiagnosticKind::RequiredPropertyNotSystem { .. } => { + ("DSC0060", "RequiredPropertyNotSystem") + } + DiagnosticKind::DuplicateRequiredProperty { .. } => { + ("DSC0061", "DuplicateRequiredProperty") + } + DiagnosticKind::TimeRangeSourceNotRequired { .. } => { + ("DSC0062", "TimeRangeSourceNotRequired") + } + } + } + + /// The stable code, `DSC` followed by four digits. Provisional. + pub fn code(&self) -> &'static str { + self.code_and_name().0 + } + + /// The variant name. + pub fn name(&self) -> &'static str { + self.code_and_name().1 + } +} + +impl fmt::Display for DiagnosticKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DiagnosticKind::UnknownAttribute { attribute } => { + write!(f, "unknown attribute `{attribute}`") + } + DiagnosticKind::UnknownOption { attribute, option } => { + write!(f, "unknown option `{option}` on `{attribute}`") + } + DiagnosticKind::InvalidOptionValue { + attribute, + option, + reason, + } => write!(f, "invalid value for `{option}` on `{attribute}`: {reason}"), + DiagnosticKind::MissingOption { attribute, option } => { + write!(f, "`{attribute}` requires option `{option}`") + } + DiagnosticKind::DuplicateOption { attribute, option } => { + write!(f, "option `{option}` on `{attribute}` is given twice") + } + DiagnosticKind::ExactlyOneOptionRequired { + attribute, + options, + given, + } => write!( + f, + "`{attribute}` requires exactly one of {options:?}, {given} given" + ), + DiagnosticKind::ConflictingOption { options, reason } => { + write!(f, "options {options:?} conflict: {reason}") + } + DiagnosticKind::ConflictingDeclaration { + what, + first, + second, + } => write!( + f, + "the {what} is declared differently by an {first} and a {second}" + ), + DiagnosticKind::DuplicateCollection => f.write_str("collection declared twice"), + DiagnosticKind::DuplicateIndex => f.write_str("index declared twice"), + DiagnosticKind::DuplicateMethod => f.write_str("method declared twice"), + DiagnosticKind::DuplicateExportSymbol { export } => { + write!(f, "export symbol `{export}` produced twice") + } + DiagnosticKind::DuplicateRule => f.write_str("rule declared twice"), + DiagnosticKind::DuplicateModule => f.write_str("module declared twice"), + DiagnosticKind::DuplicateInterface => f.write_str("interface declared twice"), + DiagnosticKind::DuplicateProperty => f.write_str("property declared twice"), + DiagnosticKind::DuplicatePosition { position } => { + write!(f, "position {position} used twice") + } + DiagnosticKind::NonContiguousPositions { positions } => { + write!(f, "positions {positions:?} are not contiguous from 0") + } + DiagnosticKind::DuplicateTokenCostAction { action } => { + write!(f, "token cost declared twice for action `{action}`") + } + DiagnosticKind::InvalidName(error) => write!(f, "{error}"), + DiagnosticKind::UnboundedField => { + f.write_str("variable-length value declares no maximum") + } + DiagnosticKind::IntegerBoundsOutsideType { width } => { + write!(f, "integer bounds do not fit `{width}` or are inverted") + } + DiagnosticKind::IntegerBoundNotNativelyRepresentable { bound } => write!( + f, + "bound {bound} cannot be expressed natively (bounds are signed 64-bit); omit it to keep the full width" + ), + DiagnosticKind::IndexPropertyUnknown { property } => { + write!(f, "index property `{property}` is not declared") + } + DiagnosticKind::SummablePropertyUnknown { property } => { + write!(f, "summed property `{property}` is not declared") + } + DiagnosticKind::ContestedFieldNotIndexed { property } => { + write!(f, "contested field `{property}` is not an index property") + } + DiagnosticKind::TimeRangeSourceNotFirst { property } => { + write!(f, "time range source `{property}` is not the index's first property") + } + DiagnosticKind::TerminalNotAProperty { property } => { + write!(f, "terminal `{property}` is neither `$ownerId` nor a declared property") + } + DiagnosticKind::RankedLevelNotIndexed { property } => { + write!(f, "ranked level `{property}` is not an index property") + } + DiagnosticKind::IndexOnlyOptionOnStoredCollection => { + f.write_str("terminal, preallocated and skip_if_absent need an index-only collection") + } + DiagnosticKind::SingletonWithIndexes => f.write_str("a singleton has no indexes"), + DiagnosticKind::SingletonWithDocumentIdField => { + f.write_str("a singleton has no document id field") + } + DiagnosticKind::MissingDocumentIdField => { + f.write_str("a persistent struct needs a `#[document_id]` field") + } + DiagnosticKind::InvalidSchemaRevision => f.write_str("schema revision must be at least 1"), + DiagnosticKind::ReferenceCollectionUnknown { collection } => { + write!(f, "referenced collection `{collection}` is not declared") + } + DiagnosticKind::ReferencePropertyUnknown { property } => { + write!(f, "referenced property `{property}` is not declared") + } + DiagnosticKind::ReceiverCollectionUnknown { collection } => { + write!(f, "receiver collection `{collection}` is not declared") + } + DiagnosticKind::MutableReceiverOnImmutableCollection { collection } => write!( + f, + "`&mut self` on collection `{collection}` whose documents cannot be replaced" + ), + DiagnosticKind::ReadOnlyEntryWithMutableReceiver => { + f.write_str("a read-only entry cannot take `&mut self`") + } + DiagnosticKind::DuplicateParameter { param } => { + write!(f, "parameter `{param}` declared twice") + } + DiagnosticKind::EntryModuleUnknown { module } => { + write!(f, "module `{module}` is not declared") + } + DiagnosticKind::EntryModuleRequired => { + f.write_str("the contract declares several modules; name the entry's module") + } + DiagnosticKind::InterfaceProviderUnknown { module } => { + write!(f, "provider module `{module}` is not declared") + } + DiagnosticKind::UsedInterfaceUnknown { interface } => { + write!(f, "interface `{interface}` is not declared") + } + DiagnosticKind::ModuleSelfImport { interface } => { + write!(f, "module uses interface `{interface}` that it provides itself") + } + DiagnosticKind::ModuleGraphCycle { modules } => { + write!(f, "module imports form a cycle: {modules:?}") + } + DiagnosticKind::DuplicateInterfaceFunction { function } => { + write!(f, "interface function `{function}` declared twice") + } + DiagnosticKind::PredicateModuleUnknown { module } => { + write!(f, "predicate module `{module}` is not declared") + } + DiagnosticKind::RuleCollectionUnknown { collection } => { + write!(f, "rule collection `{collection}` is not declared") + } + DiagnosticKind::RuleWithoutActions => f.write_str("a rule needs at least one action"), + DiagnosticKind::GuardFieldUnknown { property } => { + write!(f, "guard reads property `{property}` that is not declared") + } + DiagnosticKind::CapabilityInterfaceDisabled { requirement } => write!( + f, + "capability `{requirement}` is catalogued but its interface is disabled" + ), + DiagnosticKind::CapabilityNotDeclarable { requirement } => write!( + f, + "capability `{requirement}` is derived from declarations and cannot be required explicitly" + ), + DiagnosticKind::RawPathDeclaration => { + f.write_str("raw database paths are not declarable") + } + DiagnosticKind::LengthBoundsInverted { min, max } => { + write!(f, "minimum length {min} is above maximum length {max}") + } + DiagnosticKind::DuplicateStructMember { member } => { + write!(f, "struct member `{member}` declared twice") + } + DiagnosticKind::DuplicateContestedField { property } => { + write!(f, "contested field match `{property}` declared twice") + } + DiagnosticKind::DuplicateAgreementProperty { property } => { + write!(f, "agreement property `{property}` declared twice") + } + DiagnosticKind::DuplicateRankedLevel { property } => { + write!(f, "ranked level `{property}` named twice; each level is one ranking") + } + DiagnosticKind::RequiredPropertyNotSystem { property } => write!( + f, + "`{property}` is not a system property; require a user field on the field itself" + ), + DiagnosticKind::DuplicateRequiredProperty { property } => { + write!(f, "required system property `{property}` listed twice") + } + DiagnosticKind::TimeRangeSourceNotRequired { property } => write!( + f, + "time range buckets `{property}`, which the collection does not require; a system timestamp is populated only when required" + ), + } + } +} + +/// One diagnostic: where and what. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Diagnostic { + /// Where the diagnostic points. + pub path: DeclarationPath, + /// What went wrong. + pub kind: DiagnosticKind, +} + +impl Diagnostic { + /// A diagnostic at `path`. + pub fn new(path: DeclarationPath, kind: DiagnosticKind) -> Self { + Diagnostic { path, kind } + } + + /// The stable code of the kind. + pub fn code(&self) -> &'static str { + self.kind.code() + } + + /// Where the diagnostic points. + pub fn path(&self) -> &DeclarationPath { + &self.path + } + + /// Wraps a name grammar failure. + pub fn invalid_name(path: DeclarationPath, error: InvalidName) -> Self { + Diagnostic::new(path, DiagnosticKind::InvalidName(error)) + } +} + +impl fmt::Display for Diagnostic { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} at {}: {}", self.kind.code(), self.path, self.kind) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_render_code_path_and_message() { + let diagnostic = Diagnostic::new( + DeclarationPath::collection("scores"), + DiagnosticKind::DuplicateCollection, + ); + assert_eq!( + diagnostic.to_string(), + "DSC0009 at collection scores: collection declared twice" + ); + assert_eq!(diagnostic.kind.name(), "DuplicateCollection"); + } + + #[test] + fn should_render_every_path_shape() { + let paths = [ + DeclarationPath::Contract, + DeclarationPath::attribute("index", Some("fields")), + DeclarationPath::attribute("index", None), + DeclarationPath::module("main"), + DeclarationPath::interface("math"), + DeclarationPath::collection("scores"), + DeclarationPath::field("scores", "profile.age"), + DeclarationPath::index("scores", "by_class"), + DeclarationPath::typed_collection("totals"), + DeclarationPath::entry("score.add"), + DeclarationPath::entry_param("score.add", "delta"), + DeclarationPath::rule("scores", "monotonic"), + DeclarationPath::Capability(CapabilityRequirement::PrivateStore), + DeclarationPath::interface_param("math", "add", "return"), + DeclarationPath::entry_param("f", "profile") + .member("avatars") + .member("[]"), + ]; + for path in paths { + assert!(!path.to_string().is_empty()); + } + } +} diff --git a/packages/rs-dash-sdk-contract/src/validate/entries.rs b/packages/rs-dash-sdk-contract/src/validate/entries.rs new file mode 100644 index 00000000000..74fabecacf2 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/validate/entries.rs @@ -0,0 +1,152 @@ +//! Entry checks: identity, export symbols, receivers, module bindings and +//! bounded wire types. + +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use crate::declare::{implicit_module, ContractDeclaration, EntrySpec, Receiver}; +use crate::identity::{entry_export_symbol, ModuleName}; +use crate::manifest::{MethodEntry, MethodTable, ModuleTable}; +use crate::validate::collections::{check_value_type, CollectionKinds}; +use crate::validate::diagnostic::{DeclarationPath, Diagnostic, DiagnosticKind}; +use crate::validate::merge::dedupe; + +pub(super) fn validate_entries( + declaration: &ContractDeclaration, + modules: &ModuleTable, + collections: &CollectionKinds, + diagnostics: &mut Vec, +) -> MethodTable { + let entries = dedupe( + &declaration.entries, + |spec| DeclarationPath::entry(&spec.name), + diagnostics, + ); + + let single_module: Option<&ModuleName> = match modules.modules.as_slice() { + [only] => Some(&only.name), + _ => None, + }; + + let mut exports: Vec = Vec::new(); + let mut table: Vec = Vec::new(); + for entry in &entries { + let path = DeclarationPath::entry(&entry.name); + + let export = entry_export_symbol(&entry.name); + if exports.contains(&export) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::DuplicateExportSymbol { + export: export.clone(), + }, + )); + } else { + exports.push(export.clone()); + } + + let module = resolve_module(entry, modules, single_module, &path, diagnostics); + + let summary = entry + .receiver + .collection() + .and_then(|name| collections.iter().find(|summary| &summary.name == name)); + if let Some(name) = entry.receiver.collection() { + if summary.is_none() { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::ReceiverCollectionUnknown { + collection: name.to_string(), + }, + )); + } + } + if let Receiver::Mut(name) = &entry.receiver { + if entry.read_only { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::ReadOnlyEntryWithMutableReceiver, + )); + } + if summary.is_some_and(|summary| !summary.mutable) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::MutableReceiverOnImmutableCollection { + collection: name.to_string(), + }, + )); + } + } + + let mut params: Vec<&str> = Vec::new(); + for param in &entry.params { + let param_path = DeclarationPath::entry_param(&entry.name, ¶m.name); + if params.contains(¶m.name.as_str()) { + diagnostics.push(Diagnostic::new( + param_path.clone(), + DiagnosticKind::DuplicateParameter { + param: param.name.clone(), + }, + )); + } else { + params.push(¶m.name); + } + check_value_type(¶m_path, ¶m.ty, diagnostics); + } + check_value_type( + &DeclarationPath::entry_param(&entry.name, "return"), + &entry.returns, + diagnostics, + ); + + table.push(MethodEntry { + name: entry.name.clone(), + module, + export, + receiver: entry.receiver.clone(), + takes_document_id: MethodEntry::receiver_takes_document_id( + &entry.receiver, + summary.map(|summary| summary.kind), + ), + read_only: entry.read_only, + params: entry.params.clone(), + returns: entry.returns.clone(), + }); + } + table.sort_by(|a, b| a.name.cmp(&b.name)); + MethodTable { entries: table } +} + +fn resolve_module( + entry: &EntrySpec, + modules: &ModuleTable, + single_module: Option<&ModuleName>, + path: &DeclarationPath, + diagnostics: &mut Vec, +) -> ModuleName { + match (&entry.module, single_module) { + (Some(module), _) => { + if !modules.names().any(|name| name == module) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::EntryModuleUnknown { + module: module.to_string(), + }, + )); + } + module.clone() + } + (None, Some(single)) => single.clone(), + (None, None) => { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::EntryModuleRequired, + )); + modules + .modules + .first() + .map(|module| module.name.clone()) + .unwrap_or_else(implicit_module) + } + } +} diff --git a/packages/rs-dash-sdk-contract/src/validate/merge.rs b/packages/rs-dash-sdk-contract/src/validate/merge.rs new file mode 100644 index 00000000000..bf18bb74573 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/validate/merge.rs @@ -0,0 +1,298 @@ +//! Merging attribute and builder declarations of one item. +//! +//! Two specs with the same identity are one item when they come from +//! different origins and agree; a builder may restate what an attribute +//! declared. Two specs of the same origin with one identity are a duplicate, +//! and two specs of different origins that disagree are a conflict. + +use alloc::string::ToString; +use alloc::vec::Vec; + +use crate::declare::{ + CollectionSpec, DeclarationOrigin, EntrySpec, IndexSpec, InterfaceSpec, ModuleSpec, RuleSpec, + TypedCollectionSpec, +}; +use crate::validate::diagnostic::{DeclarationPath, Diagnostic, DiagnosticKind}; + +/// A spec with a declared identity and an origin. +pub(super) trait Identified: Clone { + /// What the item is called in a conflict diagnostic. + const WHAT: &'static str; + + /// Whether the two specs declare the same item. + fn same_identity(&self, other: &Self) -> bool; + + /// Where the spec came from. + fn origin(&self) -> DeclarationOrigin; + + /// Whether the two specs agree apart from origin (and whatever `merge` + /// may legitimately union). + fn equivalent(&self, other: &Self) -> bool; + + /// Unions what a restatement may add; nothing by default. + fn merge(&mut self, _other: &Self) {} + + /// The diagnostic for two specs of one origin sharing an identity. + fn duplicate() -> DiagnosticKind; +} + +/// Same content apart from origin. +fn equal_ignoring_origin(a: &T, b: &T) -> bool { + let mut left = a.clone(); + left.set_origin(b.origin()); + left == *b +} + +/// Origin access shared by the spec types. +trait HasOrigin { + fn origin(&self) -> DeclarationOrigin; + fn set_origin(&mut self, origin: DeclarationOrigin); +} + +macro_rules! has_origin { + ($($ty:ty),* $(,)?) => { + $( + impl HasOrigin for $ty { + fn origin(&self) -> DeclarationOrigin { + self.origin + } + + fn set_origin(&mut self, origin: DeclarationOrigin) { + self.origin = origin; + } + } + )* + }; +} + +has_origin!( + ModuleSpec, + InterfaceSpec, + CollectionSpec, + IndexSpec, + TypedCollectionSpec, + EntrySpec, + RuleSpec, +); + +impl Identified for ModuleSpec { + const WHAT: &'static str = "module"; + + fn same_identity(&self, other: &Self) -> bool { + self.name == other.name + } + + fn origin(&self) -> DeclarationOrigin { + self.origin + } + + fn equivalent(&self, other: &Self) -> bool { + sorted(&self.uses) == sorted(&other.uses) + } + + fn duplicate() -> DiagnosticKind { + DiagnosticKind::DuplicateModule + } +} + +impl Identified for InterfaceSpec { + const WHAT: &'static str = "interface"; + + fn same_identity(&self, other: &Self) -> bool { + self.name == other.name + } + + fn origin(&self) -> DeclarationOrigin { + self.origin + } + + fn equivalent(&self, other: &Self) -> bool { + let mut left = self.functions.clone(); + left.sort_by(|a, b| a.name.cmp(&b.name)); + let mut right = other.functions.clone(); + right.sort_by(|a, b| a.name.cmp(&b.name)); + self.provider == other.provider && left == right + } + + fn duplicate() -> DiagnosticKind { + DiagnosticKind::DuplicateInterface + } +} + +impl Identified for CollectionSpec { + const WHAT: &'static str = "collection"; + + fn same_identity(&self, other: &Self) -> bool { + self.name == other.name + } + + fn origin(&self) -> DeclarationOrigin { + self.origin + } + + fn equivalent(&self, other: &Self) -> bool { + self.same_shape_ignoring_indexes(other) + } + + fn merge(&mut self, other: &Self) { + self.indexes.extend(other.indexes.iter().cloned()); + } + + fn duplicate() -> DiagnosticKind { + DiagnosticKind::DuplicateCollection + } +} + +impl Identified for IndexSpec { + const WHAT: &'static str = "index"; + + fn same_identity(&self, other: &Self) -> bool { + self.name == other.name + } + + fn origin(&self) -> DeclarationOrigin { + self.origin + } + + fn equivalent(&self, other: &Self) -> bool { + equal_ignoring_origin(&self.normalized(), &other.normalized()) + } + + fn duplicate() -> DiagnosticKind { + DiagnosticKind::DuplicateIndex + } +} + +impl Identified for TypedCollectionSpec { + const WHAT: &'static str = "typed collection"; + + fn same_identity(&self, other: &Self) -> bool { + self.id == other.id + } + + fn origin(&self) -> DeclarationOrigin { + self.origin + } + + fn equivalent(&self, other: &Self) -> bool { + equal_ignoring_origin(self, other) + } + + fn duplicate() -> DiagnosticKind { + DiagnosticKind::DuplicateCollection + } +} + +impl Identified for EntrySpec { + const WHAT: &'static str = "entry"; + + fn same_identity(&self, other: &Self) -> bool { + self.name == other.name + } + + fn origin(&self) -> DeclarationOrigin { + self.origin + } + + fn equivalent(&self, other: &Self) -> bool { + equal_ignoring_origin(self, other) + } + + fn duplicate() -> DiagnosticKind { + DiagnosticKind::DuplicateMethod + } +} + +impl Identified for RuleSpec { + const WHAT: &'static str = "rule"; + + fn same_identity(&self, other: &Self) -> bool { + self.collection == other.collection && self.name == other.name + } + + fn origin(&self) -> DeclarationOrigin { + self.origin + } + + fn equivalent(&self, other: &Self) -> bool { + let mut left = self.clone(); + left.origin = other.origin; + left.actions = sorted(&left.actions); + let mut right = other.clone(); + right.actions = sorted(&right.actions); + left == right + } + + fn duplicate() -> DiagnosticKind { + DiagnosticKind::DuplicateRule + } +} + +/// Sorted and deduplicated copy. +pub(super) fn sorted(items: &[T]) -> Vec { + let mut sorted = items.to_vec(); + sorted.sort(); + sorted.dedup(); + sorted +} + +/// Deduplicates `items` by identity and returns one spec per identity in +/// first-seen order. +/// +/// Items are grouped by identity before anything is judged, so the outcome +/// does not depend on declaration order: every repeated declaration of one +/// origin is a duplicate whatever its content, every attribute-versus-builder +/// pair that disagrees is a conflict, and a builder that agrees with the +/// attribute is merged into it (which, for a collection, adds its indexes). +pub(super) fn dedupe( + items: &[T], + path: impl Fn(&T) -> DeclarationPath, + diagnostics: &mut Vec, +) -> Vec { + let mut groups: Vec> = Vec::new(); + for item in items { + match groups.iter_mut().find(|group| group[0].same_identity(item)) { + Some(group) => group.push(item), + None => groups.push(alloc::vec![item]), + } + } + + groups + .into_iter() + .map(|group| { + let path = path(group[0]); + for origin in [DeclarationOrigin::Attribute, DeclarationOrigin::Builder] { + let repeats = group.iter().filter(|item| item.origin() == origin).count(); + for _ in 1..repeats { + diagnostics.push(Diagnostic::new(path.clone(), T::duplicate())); + } + } + let of = |origin: DeclarationOrigin| { + group + .iter() + .copied() + .filter(move |item| item.origin() == origin) + }; + let conflict = of(DeclarationOrigin::Attribute).any(|attribute| { + of(DeclarationOrigin::Builder).any(|builder| !attribute.equivalent(builder)) + }); + if conflict { + diagnostics.push(Diagnostic::new( + path, + DiagnosticKind::ConflictingDeclaration { + what: T::WHAT.to_string(), + first: DeclarationOrigin::Attribute, + second: DeclarationOrigin::Builder, + }, + )); + } + let mut kept = group[0].clone(); + for item in &group[1..] { + if kept.equivalent(item) { + kept.merge(item); + } + } + kept + }) + .collect() +} diff --git a/packages/rs-dash-sdk-contract/src/validate/mod.rs b/packages/rs-dash-sdk-contract/src/validate/mod.rs new file mode 100644 index 00000000000..467fc8c61c9 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/validate/mod.rs @@ -0,0 +1,83 @@ +//! Validation: from a [`ContractDeclaration`] to a [`CanonicalManifest`] or a +//! complete list of [`Diagnostic`]s. +//! +//! The validator owns what the native host cannot know: attribute grammar, +//! identity uniqueness, conflicts between an attribute and a builder, +//! boundedness of fields and wire types, cross-references between +//! declarations, module graph shape and the SDK's own semantic rules (a +//! mutable receiver on an immutable collection, a singleton with indexes). +//! It deliberately does not mirror native numeric limits (index counts, name +//! lengths, indexed string sizes, time-range overlap caps) or native schema +//! dependencies (a range count needs a count): those are enforced once, by +//! Dash Platform Protocol, and the build crate surfaces them. +//! +//! Every check runs and every diagnostic is collected; there is no +//! first-error return. + +pub mod diagnostic; + +mod capabilities; +mod collections; +mod entries; +mod merge; +mod modules; +mod rules; +#[cfg(test)] +mod tests; + +use alloc::vec::Vec; + +pub use diagnostic::{DeclarationPath, Diagnostic, DiagnosticKind}; + +use crate::declare::ContractDeclaration; +use crate::manifest::CanonicalManifest; + +/// Validates the declaration and builds its canonical manifest, or returns +/// every diagnostic found. +pub fn validate(declaration: &ContractDeclaration) -> Result> { + let mut diagnostics = Vec::new(); + + let module_table = modules::validate_modules(declaration, &mut diagnostics); + let (collection_manifests, collection_kinds) = + collections::validate_collections(declaration, &mut diagnostics); + let typed_collections = collections::validate_typed_collections( + declaration, + &collection_manifests, + &mut diagnostics, + ); + let method_table = entries::validate_entries( + declaration, + &module_table, + &collection_kinds, + &mut diagnostics, + ); + let rule_manifests = rules::validate_rules( + declaration, + &module_table, + &collection_manifests, + &mut diagnostics, + ); + let capability_table = capabilities::validate_capabilities( + declaration, + &module_table, + &collection_manifests, + &typed_collections, + &method_table, + &rule_manifests, + &mut diagnostics, + ); + + if !diagnostics.is_empty() { + return Err(diagnostics); + } + + Ok(CanonicalManifest { + modules: module_table, + collections: collection_manifests, + typed_collections, + methods: method_table, + rules: rule_manifests, + capabilities: capability_table, + receipts: declaration.receipts, + }) +} diff --git a/packages/rs-dash-sdk-contract/src/validate/modules.rs b/packages/rs-dash-sdk-contract/src/validate/modules.rs new file mode 100644 index 00000000000..652d8651769 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/validate/modules.rs @@ -0,0 +1,230 @@ +//! Module and interface checks: identity, providers, imports and the acyclic +//! import graph. + +use alloc::string::ToString; +use alloc::vec::Vec; + +use crate::declare::{ + implicit_module, ContractDeclaration, InterfaceSpec, InternalFunctionSpec, ModuleSpec, +}; +use crate::identity::{InterfaceName, ModuleName}; +use crate::manifest::{Binding, InterfaceEntry, ModuleEntry, ModuleTable}; +use crate::validate::collections::check_value_type; +use crate::validate::diagnostic::{DeclarationPath, Diagnostic, DiagnosticKind}; +use crate::validate::merge::{dedupe, sorted}; + +pub(super) fn validate_modules( + declaration: &ContractDeclaration, + diagnostics: &mut Vec, +) -> ModuleTable { + let mut modules = dedupe( + &declaration.modules, + |spec| DeclarationPath::module(&spec.name), + diagnostics, + ); + if modules.is_empty() { + modules.push(ModuleSpec::new(implicit_module())); + } + + let interfaces = dedupe( + &declaration.interfaces, + |spec| DeclarationPath::interface(&spec.name), + diagnostics, + ); + + let module_names: Vec<&ModuleName> = modules.iter().map(|module| &module.name).collect(); + + for interface in &interfaces { + if !module_names.contains(&&interface.provider) { + diagnostics.push(Diagnostic::new( + DeclarationPath::interface(&interface.name), + DiagnosticKind::InterfaceProviderUnknown { + module: interface.provider.to_string(), + }, + )); + } + let mut seen: Vec<&str> = Vec::new(); + for function in &interface.functions { + if seen.contains(&function.name.as_str()) { + diagnostics.push(Diagnostic::new( + DeclarationPath::interface(&interface.name), + DiagnosticKind::DuplicateInterfaceFunction { + function: function.name.clone(), + }, + )); + } else { + seen.push(&function.name); + } + let mut params: Vec<&str> = Vec::new(); + for param in &function.params { + let param_path = + DeclarationPath::interface_param(&interface.name, &function.name, ¶m.name); + if params.contains(¶m.name.as_str()) { + diagnostics.push(Diagnostic::new( + param_path.clone(), + DiagnosticKind::DuplicateParameter { + param: param.name.clone(), + }, + )); + } else { + params.push(¶m.name); + } + check_value_type(¶m_path, ¶m.ty, diagnostics); + } + check_value_type( + &DeclarationPath::interface_param(&interface.name, &function.name, "return"), + &function.returns, + diagnostics, + ); + } + } + + let mut bindings: Vec = Vec::new(); + for module in &modules { + for used in &module.uses { + let Some(interface) = interfaces.iter().find(|interface| &interface.name == used) + else { + diagnostics.push(Diagnostic::new( + DeclarationPath::module(&module.name), + DiagnosticKind::UsedInterfaceUnknown { + interface: used.to_string(), + }, + )); + continue; + }; + if interface.provider == module.name { + diagnostics.push(Diagnostic::new( + DeclarationPath::module(&module.name), + DiagnosticKind::ModuleSelfImport { + interface: used.to_string(), + }, + )); + continue; + } + let binding = Binding { + importer: module.name.clone(), + provider: interface.provider.clone(), + interface: used.clone(), + }; + if !bindings.contains(&binding) { + bindings.push(binding); + } + } + } + + if let Some(cycle) = find_cycle(&module_names, &bindings) { + diagnostics.push(Diagnostic::new( + DeclarationPath::module(&cycle[0]), + DiagnosticKind::ModuleGraphCycle { + modules: cycle.iter().map(|name| name.to_string()).collect(), + }, + )); + } + + let mut module_entries: Vec = modules + .iter() + .map(|module| ModuleEntry { + name: module.name.clone(), + uses: sorted_uses(module), + }) + .collect(); + module_entries.sort_by(|a, b| a.name.cmp(&b.name)); + + let mut interface_entries: Vec = interfaces + .iter() + .map(|interface| InterfaceEntry { + name: interface.name.clone(), + provider: interface.provider.clone(), + functions: sorted_functions(interface), + }) + .collect(); + interface_entries.sort_by(|a, b| a.name.cmp(&b.name)); + + bindings.sort(); + + ModuleTable { + modules: module_entries, + interfaces: interface_entries, + bindings, + } +} + +fn sorted_uses(module: &ModuleSpec) -> Vec { + sorted(&module.uses) +} + +fn sorted_functions(interface: &InterfaceSpec) -> Vec { + let mut functions = interface.functions.clone(); + functions.sort_by(|a, b| a.name.cmp(&b.name)); + functions +} + +/// Depth-first search over the importer-to-provider edges; returns the +/// modules on the first cycle found, starting and ending at the same module +/// omitted. +fn find_cycle(modules: &[&ModuleName], bindings: &[Binding]) -> Option> { + #[derive(Clone, Copy, PartialEq)] + enum Mark { + Unvisited, + Active, + Done, + } + + fn visit( + node: usize, + modules: &[&ModuleName], + bindings: &[Binding], + marks: &mut [Mark], + stack: &mut Vec, + ) -> Option> { + marks[node] = Mark::Active; + stack.push(node); + for binding in bindings + .iter() + .filter(|binding| &binding.importer == modules[node]) + { + let Some(next) = modules + .iter() + .position(|module| **module == binding.provider) + else { + continue; + }; + match marks[next] { + Mark::Active => { + // An active module is on the stack by construction: it was + // pushed when marked and is popped only when marked done. + let start = stack + .iter() + .position(|&index| index == next) + .expect("an active module is on the search stack"); + return Some( + stack[start..] + .iter() + .map(|&index| modules[index].clone()) + .collect(), + ); + } + Mark::Unvisited => { + if let Some(cycle) = visit(next, modules, bindings, marks, stack) { + return Some(cycle); + } + } + Mark::Done => {} + } + } + stack.pop(); + marks[node] = Mark::Done; + None + } + + let mut marks = alloc::vec![Mark::Unvisited; modules.len()]; + let mut stack = Vec::new(); + for node in 0..modules.len() { + if marks[node] == Mark::Unvisited { + if let Some(cycle) = visit(node, modules, bindings, &mut marks, &mut stack) { + return Some(cycle); + } + } + } + None +} diff --git a/packages/rs-dash-sdk-contract/src/validate/rules.rs b/packages/rs-dash-sdk-contract/src/validate/rules.rs new file mode 100644 index 00000000000..fb8575f5f46 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/validate/rules.rs @@ -0,0 +1,89 @@ +//! Rule checks: identity, scope, kind and guard field references. + +use alloc::string::ToString; +use alloc::vec::Vec; + +use crate::declare::{ContractDeclaration, FieldContext, RuleKind}; +use crate::manifest::{CollectionManifest, ModuleTable, RuleManifest}; +use crate::validate::collections::has_property_path; +use crate::validate::diagnostic::{DeclarationPath, Diagnostic, DiagnosticKind}; +use crate::validate::merge::{dedupe, sorted}; + +pub(super) fn validate_rules( + declaration: &ContractDeclaration, + modules: &ModuleTable, + collections: &[CollectionManifest], + diagnostics: &mut Vec, +) -> Vec { + let rules = dedupe( + &declaration.rules, + |spec| DeclarationPath::rule(&spec.collection, &spec.name), + diagnostics, + ); + + let mut manifests: Vec = rules + .iter() + .map(|rule| { + let path = DeclarationPath::rule(&rule.collection, &rule.name); + let collection = collections + .iter() + .find(|collection| collection.name == rule.collection); + if collection.is_none() { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::RuleCollectionUnknown { + collection: rule.collection.to_string(), + }, + )); + } + let actions = sorted(&rule.actions); + if actions.is_empty() { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::RuleWithoutActions, + )); + } + match &rule.kind { + RuleKind::NativeGuard(guard) => { + if let Some(collection) = collection { + for (context, property) in guard.field_references() { + let known = match context { + FieldContext::Old | FieldContext::New => { + has_property_path(&collection.fields, property) + } + // The action context's fields are host defined. + FieldContext::Context => true, + }; + if !known { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::GuardFieldUnknown { + property: property.to_string(), + }, + )); + } + } + } + } + RuleKind::WasmPredicate { module, .. } => { + if !modules.names().any(|name| name == module) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::PredicateModuleUnknown { + module: module.to_string(), + }, + )); + } + } + } + RuleManifest { + collection: rule.collection.clone(), + name: rule.name.clone(), + actions, + kind: rule.kind.clone(), + } + }) + .collect(); + manifests.sort_by(|a, b| (&a.collection, &a.name).cmp(&(&b.collection, &b.name))); + manifests +} diff --git a/packages/rs-dash-sdk-contract/src/validate/tests.rs b/packages/rs-dash-sdk-contract/src/validate/tests.rs new file mode 100644 index 00000000000..3db04e08505 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/validate/tests.rs @@ -0,0 +1,1965 @@ +//! Validator tests: the sketch, one `should_report_*` test per producible +//! diagnostic, sugar expansion and manifest order independence. + +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; + +use crate::declare::*; +use crate::identity::*; +use crate::manifest::CanonicalManifest; +use crate::validate::{validate, DeclarationPath, Diagnostic, DiagnosticKind}; + +fn collection(name: &str) -> CollectionName { + CollectionName::new(name).unwrap() +} + +fn property(name: &str) -> PropertyName { + PropertyName::new(name).unwrap() +} + +fn path(name: &str) -> PropertyPath { + PropertyPath::new(name).unwrap() +} + +fn index_name(name: &str) -> IndexName { + IndexName::new(name).unwrap() +} + +fn method(name: &str) -> MethodName { + MethodName::new(name).unwrap() +} + +fn module(name: &str) -> ModuleName { + ModuleName::new(name).unwrap() +} + +fn interface(name: &str) -> InterfaceName { + InterfaceName::new(name).unwrap() +} + +fn rule(name: &str) -> RuleName { + RuleName::new(name).unwrap() +} + +/// The `Score` collection of the issue's API sketch, with the ascending-only +/// correction (`points = "desc"` is not expressible natively) and the given +/// write policy. +pub(crate) fn score_collection(write: WritePolicy) -> CollectionSpec { + CollectionSpec::documents(collection("scores")) + .with_origin(DeclarationOrigin::Attribute) + .schema_revision(1) + .write(write) + .document_id_field("id") + .field(FieldSpec::new(property("class"), 0, FieldType::string(64))) + .field(FieldSpec::new( + property("points"), + 1, + FieldType::bounded_integer(IntegerWidth::I64, 0, 1_000_000), + )) + .field(FieldSpec::new(property("owner"), 2, FieldType::identity())) + .index( + IndexSpec::new(index_name("by_class"), vec![path("class")]) + .with_origin(DeclarationOrigin::Attribute) + .count() + .sum(property("points")), + ) + .index( + IndexSpec::new(index_name("by_owner"), vec![path("owner")]) + .with_origin(DeclarationOrigin::Attribute), + ) + .index( + IndexSpec::new(index_name("ranking"), vec![path("class"), path("points")]) + .with_origin(DeclarationOrigin::Attribute) + .ranked_count(), + ) +} + +/// The sketch's entries: a mutable receiver, a free creator, a free pair edit +/// and a read-only aggregate. +pub(crate) fn score_entries() -> Vec { + vec![ + EntrySpec::new(method("score.add")) + .with_origin(DeclarationOrigin::Attribute) + .receiver(Receiver::Mut(collection("scores"))) + .param("delta", ValueType::Integer(IntegerWidth::I64)), + EntrySpec::new(method("score.create")) + .with_origin(DeclarationOrigin::Attribute) + .param("class", ValueType::string(64)) + .param("points", ValueType::Integer(IntegerWidth::I64)) + .returns(ValueType::DocumentId), + EntrySpec::new(method("score.add_pair")) + .with_origin(DeclarationOrigin::Attribute) + .param("first", ValueType::DocumentId) + .param("second", ValueType::DocumentId), + EntrySpec::new(method("score.total")) + .with_origin(DeclarationOrigin::Attribute) + .read_only(true) + .param("class", ValueType::string(64)) + .returns(ValueType::Struct(vec![ + ("count".to_string(), ValueType::Integer(IntegerWidth::U64)), + ("sum".to_string(), ValueType::Integer(IntegerWidth::I64)), + ])), + ] +} + +pub(crate) fn score_sketch(write: WritePolicy) -> ContractDeclaration { + let mut declaration = ContractDeclaration::new().collection(score_collection(write)); + for entry in score_entries() { + declaration = declaration.entry(entry); + } + declaration.require(CapabilityRequirement::Acl) +} + +fn kinds(diagnostics: &[Diagnostic]) -> Vec<&'static str> { + diagnostics.iter().map(|d| d.kind.name()).collect() +} + +fn expect_diagnostics(declaration: &ContractDeclaration) -> Vec { + match validate(declaration) { + Ok(_) => panic!("expected diagnostics"), + Err(diagnostics) => diagnostics, + } +} + +fn expect_manifest(declaration: &ContractDeclaration) -> CanonicalManifest { + match validate(declaration) { + Ok(manifest) => manifest, + Err(diagnostics) => panic!("unexpected diagnostics: {diagnostics:#?}"), + } +} + +fn assert_reports(declaration: &ContractDeclaration, kind: &str) -> Vec { + let diagnostics = expect_diagnostics(declaration); + assert!( + kinds(&diagnostics).contains(&kind), + "expected {kind}, got {:?}", + kinds(&diagnostics) + ); + diagnostics +} + +fn minimal(name: &str) -> CollectionSpec { + CollectionSpec::documents(collection(name)) + .document_id_field("id") + .field(FieldSpec::new(property("a"), 0, FieldType::Bool)) +} + +#[test] +fn should_validate_the_sketch_with_owner_writes() { + let manifest = expect_manifest(&score_sketch(WritePolicy::Owner)); + let scores = manifest.collection("scores").unwrap(); + assert_eq!(scores.indexes.len(), 3); + let by_class = scores.index("by_class").unwrap(); + assert_eq!(by_class.count, Countability::Countable); + assert_eq!(by_class.sum, Some(property("points"))); + assert_eq!( + scores.index("ranking").unwrap().ranked.count, + RankedCount::Terminal + ); + let names: Vec<&str> = manifest.methods.names().map(|n| n.as_str()).collect(); + assert_eq!( + names, + ["score.add", "score.add_pair", "score.create", "score.total"] + ); + let add = manifest.method("score.add").unwrap(); + assert_eq!(add.module.as_str(), "main"); + assert_eq!(add.export, "dash_entry_score.add"); + assert!(add.takes_document_id); + assert!(!manifest.method("score.create").unwrap().takes_document_id); + assert!(manifest.methods.entry("score.total").unwrap().read_only); + assert!(manifest.capabilities.requires(CapabilityRequirement::Acl)); + assert!(manifest + .capabilities + .requires(CapabilityRequirement::Entries)); + assert!(manifest + .capabilities + .requires(CapabilityRequirement::StoredReceipts)); + assert!(!manifest + .capabilities + .requires(CapabilityRequirement::ContractWrites)); + assert!(!manifest + .capabilities + .requires(CapabilityRequirement::Modules)); + assert_eq!(manifest.receipts, ReceiptPolicy::Stored); +} + +#[test] +fn should_validate_the_sketch_with_contract_writes_as_a_derived_requirement() { + let manifest = expect_manifest(&score_sketch(WritePolicy::Contract)); + assert!(manifest + .capabilities + .requires(CapabilityRequirement::ContractWrites)); + assert!(manifest + .capabilities + .pending() + .any(|entry| entry.requirement == CapabilityRequirement::ContractWrites)); +} + +#[test] +fn should_validate_an_empty_declaration_to_the_implicit_module() { + let manifest = expect_manifest(&ContractDeclaration::new()); + let names: Vec<&str> = manifest.modules.names().map(|n| n.as_str()).collect(); + assert_eq!(names, ["main"]); + assert!(manifest.methods.entries.is_empty()); + assert!(!manifest + .capabilities + .requires(CapabilityRequirement::Entries)); +} + +// Conflicts and duplicates + +#[test] +fn should_report_conflicting_declaration_between_attribute_and_builder() { + let attribute = minimal("scores").with_origin(DeclarationOrigin::Attribute); + let builder = minimal("scores") + .with_origin(DeclarationOrigin::Builder) + .mutable(false); + let declaration = ContractDeclaration::new() + .collection(attribute) + .collection(builder); + let diagnostics = assert_reports(&declaration, "ConflictingDeclaration"); + let DiagnosticKind::ConflictingDeclaration { first, second, .. } = &diagnostics[0].kind else { + panic!() + }; + assert_eq!(*first, DeclarationOrigin::Attribute); + assert_eq!(*second, DeclarationOrigin::Builder); +} + +#[test] +fn should_report_conflicting_declaration_attribute_first_whatever_the_order() { + let attribute = minimal("scores").with_origin(DeclarationOrigin::Attribute); + let builder = minimal("scores") + .with_origin(DeclarationOrigin::Builder) + .mutable(false); + let declaration = ContractDeclaration::new() + .collection(builder) + .collection(attribute); + let diagnostics = expect_diagnostics(&declaration); + assert_eq!(kinds(&diagnostics), ["ConflictingDeclaration"]); + let DiagnosticKind::ConflictingDeclaration { first, second, .. } = &diagnostics[0].kind else { + panic!() + }; + assert_eq!(*first, DeclarationOrigin::Attribute); + assert_eq!(*second, DeclarationOrigin::Builder); +} + +#[test] +fn should_report_duplicate_builder_declarations_whatever_the_order() { + let attribute = minimal("scores").with_origin(DeclarationOrigin::Attribute); + let first = minimal("scores") + .with_origin(DeclarationOrigin::Builder) + .index(IndexSpec::new(index_name("by_a"), vec![path("a")])); + let second = minimal("scores") + .with_origin(DeclarationOrigin::Builder) + .index(IndexSpec::new(index_name("by_a_too"), vec![path("a")])); + let orders = [ + [attribute.clone(), first.clone(), second.clone()], + [first.clone(), attribute.clone(), second.clone()], + [first, second, attribute], + ]; + for order in orders { + let mut declaration = ContractDeclaration::new(); + for collection in order { + declaration = declaration.collection(collection); + } + let diagnostics = expect_diagnostics(&declaration); + assert_eq!(kinds(&diagnostics), ["DuplicateCollection"]); + } +} + +#[test] +fn should_report_both_a_duplicate_and_a_conflict_whatever_the_order() { + let attribute = minimal("scores").with_origin(DeclarationOrigin::Attribute); + let agreeing = minimal("scores").with_origin(DeclarationOrigin::Builder); + let disagreeing = minimal("scores") + .with_origin(DeclarationOrigin::Builder) + .mutable(false); + let orders = [ + [attribute.clone(), agreeing.clone(), disagreeing.clone()], + [attribute.clone(), disagreeing.clone(), agreeing.clone()], + [disagreeing, agreeing, attribute], + ]; + for order in orders { + let mut declaration = ContractDeclaration::new(); + for collection in order { + declaration = declaration.collection(collection); + } + let diagnostics = expect_diagnostics(&declaration); + assert_eq!( + kinds(&diagnostics), + ["DuplicateCollection", "ConflictingDeclaration"] + ); + } +} + +#[test] +fn should_treat_a_reordered_restatement_as_equivalent() { + let ordered = CollectionSpec::documents(collection("c")) + .with_origin(DeclarationOrigin::Attribute) + .document_id_field("id") + .field(FieldSpec::new(property("a"), 0, FieldType::Bool)) + .field(FieldSpec::new( + property("nested"), + 1, + FieldType::Object(vec![ + FieldSpec::new(property("x"), 0, FieldType::Bool), + FieldSpec::new(property("y"), 1, FieldType::Bool), + ]), + )) + .token_cost(ActionScope::Create, TokenCost::new(0, 5)) + .token_cost(ActionScope::Delete, TokenCost::new(1, 3)) + .index( + IndexSpec::new(index_name("i"), vec![path("a")]) + .with_origin(DeclarationOrigin::Attribute) + .count() + .range_count(true) + .ranked_count_at(vec![path("a")]) + .contested(ContestedSpec::masternode_vote(vec![( + path("a"), + "^x".to_string(), + )])), + ); + let reordered = CollectionSpec::documents(collection("c")) + .with_origin(DeclarationOrigin::Builder) + .document_id_field("id") + .field(FieldSpec::new( + property("nested"), + 1, + FieldType::Object(vec![ + FieldSpec::new(property("y"), 1, FieldType::Bool), + FieldSpec::new(property("x"), 0, FieldType::Bool), + ]), + )) + .field(FieldSpec::new(property("a"), 0, FieldType::Bool)) + .token_cost(ActionScope::Delete, TokenCost::new(1, 3)) + .token_cost(ActionScope::Create, TokenCost::new(0, 5)) + .index( + IndexSpec::new(index_name("i"), vec![path("a")]) + .with_origin(DeclarationOrigin::Builder) + .count() + .range_count(true) + .ranked_count_at(vec![path("a")]) + .contested(ContestedSpec::masternode_vote(vec![( + path("a"), + "^x".to_string(), + )])), + ); + let merged = expect_manifest( + &ContractDeclaration::new() + .collection(ordered.clone()) + .collection(reordered), + ); + assert_eq!( + merged, + expect_manifest(&ContractDeclaration::new().collection(ordered)) + ); +} + +#[test] +fn should_let_a_builder_extend_an_attribute_collection_with_indexes() { + let attribute = minimal("scores").with_origin(DeclarationOrigin::Attribute); + let builder = minimal("scores") + .with_origin(DeclarationOrigin::Builder) + .index(IndexSpec::new(index_name("by_a"), vec![path("a")])); + let declaration = ContractDeclaration::new() + .collection(attribute) + .collection(builder); + let manifest = expect_manifest(&declaration); + assert_eq!(manifest.collection("scores").unwrap().indexes.len(), 1); +} + +#[test] +fn should_report_duplicate_collection() { + let declaration = ContractDeclaration::new() + .collection(minimal("scores")) + .collection(minimal("scores")); + assert_reports(&declaration, "DuplicateCollection"); +} + +#[test] +fn should_canonicalize_enum_value_order_and_merge_a_reordered_enum_restatement() { + let with = |values: &[&str], origin| { + CollectionSpec::documents(collection("c")) + .with_origin(origin) + .document_id_field("id") + .field(FieldSpec::new( + property("nested"), + 0, + FieldType::Object(vec![FieldSpec::new( + property("state"), + 0, + FieldType::Enum(values.iter().map(|v| v.to_string()).collect()), + )]), + )) + }; + let forward = expect_manifest( + &ContractDeclaration::new() + .collection(with(&["open", "closed"], DeclarationOrigin::Attribute)), + ); + let backward = expect_manifest( + &ContractDeclaration::new() + .collection(with(&["closed", "open"], DeclarationOrigin::Attribute)), + ); + assert_eq!(forward, backward); + let merged = expect_manifest( + &ContractDeclaration::new() + .collection(with(&["open", "closed"], DeclarationOrigin::Attribute)) + .collection(with(&["closed", "open"], DeclarationOrigin::Builder)), + ); + assert_eq!(merged, forward); + let FieldType::Object(nested) = &forward.collection("c").unwrap().fields[0].ty else { + panic!() + }; + assert_eq!( + nested[0].ty, + FieldType::Enum(vec!["closed".to_string(), "open".to_string()]) + ); +} + +#[test] +fn should_point_typed_collection_diagnostics_at_the_key_or_element() { + let declaration = ContractDeclaration::new().typed_collection(TypedCollectionSpec::new( + collection("lookup"), + TypedCollectionKind::Sum, + ValueType::String { max_chars: None }, + ValueType::Struct(vec![( + "blob".to_string(), + ValueType::Bytes { max_len: None }, + )]), + )); + let diagnostics = expect_diagnostics(&declaration); + let paths: Vec = diagnostics.iter().map(|d| d.path().to_string()).collect(); + assert_eq!( + paths, + [ + "typed collection lookup, member key", + "typed collection lookup, member element.blob", + ] + ); +} + +#[test] +fn should_report_duplicate_collection_between_typed_and_document_collections() { + let declaration = ContractDeclaration::new() + .collection(minimal("totals")) + .typed_collection(TypedCollectionSpec::new( + collection("totals"), + TypedCollectionKind::Sum, + ValueType::Identifier, + ValueType::Integer(IntegerWidth::I64), + )); + assert_reports(&declaration, "DuplicateCollection"); +} + +#[test] +fn should_report_duplicate_index() { + let spec = minimal("scores") + .index(IndexSpec::new(index_name("by_a"), vec![path("a")])) + .index(IndexSpec::new(index_name("by_a"), vec![path("a")]).unique(true)); + assert_reports( + &ContractDeclaration::new().collection(spec), + "DuplicateIndex", + ); +} + +#[test] +fn should_report_duplicate_method() { + let declaration = ContractDeclaration::new() + .entry(EntrySpec::new(method("a"))) + .entry(EntrySpec::new(method("a")).read_only(true)); + assert_reports(&declaration, "DuplicateMethod"); +} + +#[test] +fn should_report_duplicate_rule() { + let guard = GuardExpr::boolean(true); + let declaration = ContractDeclaration::new() + .collection(minimal("scores")) + .rule(RuleSpec::guard( + collection("scores"), + rule("r"), + vec![ActionScope::Create], + guard.clone(), + )) + .rule(RuleSpec::guard( + collection("scores"), + rule("r"), + vec![ActionScope::Delete], + guard, + )); + assert_reports(&declaration, "DuplicateRule"); +} + +#[test] +fn should_report_duplicate_module() { + let declaration = ContractDeclaration::new() + .module(ModuleSpec::new(module("main"))) + .module(ModuleSpec::new(module("main"))); + assert_reports(&declaration, "DuplicateModule"); +} + +#[test] +fn should_report_duplicate_interface() { + let declaration = ContractDeclaration::new() + .module(ModuleSpec::new(module("main"))) + .interface(InterfaceSpec::new(interface("math"), module("main"))) + .interface(InterfaceSpec::new(interface("math"), module("main"))); + assert_reports(&declaration, "DuplicateInterface"); +} + +#[test] +fn should_report_duplicate_property() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new(property("a"), 0, FieldType::Bool)) + .field(FieldSpec::new(property("a"), 1, FieldType::Bool)); + assert_reports( + &ContractDeclaration::new().collection(spec), + "DuplicateProperty", + ); +} + +#[test] +fn should_report_duplicate_position() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new(property("a"), 0, FieldType::Bool)) + .field(FieldSpec::new(property("b"), 0, FieldType::Bool)); + assert_reports( + &ContractDeclaration::new().collection(spec), + "DuplicatePosition", + ); +} + +#[test] +fn should_report_non_contiguous_positions_at_nested_levels() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new( + property("profile"), + 0, + FieldType::Object(vec![ + FieldSpec::new(property("age"), 0, FieldType::integer(IntegerWidth::U8)), + FieldSpec::new(property("name"), 2, FieldType::string(10)), + ]), + )); + let diagnostics = assert_reports( + &ContractDeclaration::new().collection(spec), + "NonContiguousPositions", + ); + assert_eq!( + diagnostics[0].path().to_string(), + "collection c, field profile" + ); +} + +#[test] +fn should_report_duplicate_token_cost_action() { + let spec = minimal("c") + .token_cost(ActionScope::Create, TokenCost::new(0, 1)) + .token_cost(ActionScope::Create, TokenCost::new(0, 2)); + assert_reports( + &ContractDeclaration::new().collection(spec), + "DuplicateTokenCostAction", + ); +} + +#[test] +fn should_keep_export_symbols_distinct_for_distinct_method_names() { + // The export scheme is injective, so `DuplicateExportSymbol` is a defence + // against a future scheme change and is not producible today: identical + // names are caught as a duplicate or conflicting method first. + let declaration = ContractDeclaration::new() + .entry(EntrySpec::new(method("a.b")).with_origin(DeclarationOrigin::Attribute)) + .entry( + EntrySpec::new(method("a.b")) + .with_origin(DeclarationOrigin::Builder) + .read_only(true), + ); + let diagnostics = expect_diagnostics(&declaration); + assert_eq!(kinds(&diagnostics), ["ConflictingDeclaration"]); + let declaration = ContractDeclaration::new() + .entry(EntrySpec::new(method("a.b"))) + .entry(EntrySpec::new(method("a__b"))); + let manifest = expect_manifest(&declaration); + assert_eq!(manifest.methods.entries.len(), 2); + assert_ne!( + manifest.method("a.b").unwrap().export, + manifest.method("a__b").unwrap().export + ); +} + +// Names and bounds + +#[test] +fn should_report_invalid_name_through_the_newtypes() { + let error = CollectionName::new("bad name").unwrap_err(); + let diagnostic = Diagnostic::invalid_name(DeclarationPath::collection("bad name"), error); + assert_eq!(diagnostic.kind.name(), "InvalidName"); + assert_eq!(diagnostic.code(), "DSC0020"); +} + +#[test] +fn should_report_unbounded_field_for_strings_bytes_and_lists() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new( + property("s"), + 0, + FieldType::String { + min_chars: None, + max_chars: None, + }, + )) + .field(FieldSpec::new( + property("b"), + 1, + FieldType::Bytes { + min_len: None, + max_len: None, + }, + )); + let declaration = ContractDeclaration::new().collection(spec).entry( + EntrySpec::new(method("f")) + .param( + "items", + ValueType::List { + max_len: None, + item: alloc::boxed::Box::new(ValueType::Bool), + }, + ) + .returns(ValueType::String { max_chars: None }), + ); + let diagnostics = expect_diagnostics(&declaration); + assert_eq!( + kinds(&diagnostics), + [ + "UnboundedField", + "UnboundedField", + "UnboundedField", + "UnboundedField" + ] + ); +} + +#[test] +fn should_report_length_bounds_inverted() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new( + property("s"), + 0, + FieldType::String { + min_chars: Some(9), + max_chars: Some(8), + }, + )) + .field(FieldSpec::new( + property("b"), + 1, + FieldType::Bytes { + min_len: Some(2), + max_len: Some(1), + }, + )); + let diagnostics = expect_diagnostics(&ContractDeclaration::new().collection(spec)); + assert_eq!( + kinds(&diagnostics), + ["LengthBoundsInverted", "LengthBoundsInverted"] + ); +} + +#[test] +fn should_report_duplicate_struct_member() { + let declaration = ContractDeclaration::new().entry(EntrySpec::new(method("f")).returns( + ValueType::Struct(vec![ + ("count".to_string(), ValueType::Bool), + ("count".to_string(), ValueType::Bool), + ]), + )); + assert_reports(&declaration, "DuplicateStructMember"); +} + +#[test] +fn should_report_duplicate_contested_field() { + let spec = minimal("c").index( + IndexSpec::new(index_name("i"), vec![path("a")]) + .unique(true) + .contested(ContestedSpec::masternode_vote(vec![ + (path("a"), "^x".to_string()), + (path("a"), "^y".to_string()), + ])), + ); + assert_reports( + &ContractDeclaration::new().collection(spec), + "DuplicateContestedField", + ); +} + +#[test] +fn should_report_integer_bounds_outside_type() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new( + property("a"), + 0, + FieldType::bounded_integer(IntegerWidth::U8, 0, 256), + )) + .field(FieldSpec::new( + property("b"), + 1, + FieldType::bounded_integer(IntegerWidth::I32, 10, 1), + )) + .field(FieldSpec::new( + property("c"), + 2, + FieldType::bounded_integer(IntegerWidth::U16, -1, 5), + )); + let diagnostics = expect_diagnostics(&ContractDeclaration::new().collection(spec)); + assert_eq!( + kinds(&diagnostics), + [ + "IntegerBoundsOutsideType", + "IntegerBoundsOutsideType", + "IntegerBoundsOutsideType" + ] + ); +} + +#[test] +fn should_report_integer_bound_not_natively_representable() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new( + property("a"), + 0, + FieldType::bounded_integer(IntegerWidth::U64, 0, i64::MAX as i128 + 1), + )); + let diagnostics = expect_diagnostics(&ContractDeclaration::new().collection(spec)); + assert_eq!( + kinds(&diagnostics), + ["IntegerBoundNotNativelyRepresentable"] + ); + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new( + property("a"), + 0, + FieldType::integer(IntegerWidth::U64), + )); + expect_manifest(&ContractDeclaration::new().collection(spec)); +} + +#[test] +fn should_report_invalid_schema_revision() { + let declaration = ContractDeclaration::new().collection(minimal("c").schema_revision(0)); + assert_reports(&declaration, "InvalidSchemaRevision"); +} + +// Cross-references + +#[test] +fn should_report_index_property_unknown() { + let spec = minimal("c").index(IndexSpec::new(index_name("i"), vec![path("missing")])); + assert_reports( + &ContractDeclaration::new().collection(spec), + "IndexPropertyUnknown", + ); +} + +#[test] +fn should_accept_system_properties_and_nested_paths_in_indexes() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new( + property("profile"), + 0, + FieldType::Object(vec![FieldSpec::new( + property("age"), + 0, + FieldType::integer(IntegerWidth::U8), + )]), + )) + .index(IndexSpec::new( + index_name("i"), + vec![path("$ownerId"), path("profile.age"), path("$createdAt")], + )); + expect_manifest(&ContractDeclaration::new().collection(spec)); +} + +#[test] +fn should_report_summable_property_unknown_at_index_and_collection_level() { + let spec = minimal("c") + .sum(property("missing")) + .index(IndexSpec::new(index_name("i"), vec![path("a")]).sum(property("gone"))); + let diagnostics = expect_diagnostics(&ContractDeclaration::new().collection(spec)); + assert_eq!( + kinds(&diagnostics), + ["SummablePropertyUnknown", "SummablePropertyUnknown"] + ); +} + +#[test] +fn should_report_contested_field_not_indexed() { + let spec = minimal("c").index( + IndexSpec::new(index_name("i"), vec![path("a")]) + .unique(true) + .contested(ContestedSpec::masternode_vote(vec![( + path("other"), + "^.*$".to_string(), + )])), + ); + assert_reports( + &ContractDeclaration::new().collection(spec), + "ContestedFieldNotIndexed", + ); +} + +#[test] +fn should_accept_a_contested_index_next_to_a_create_rule() { + let spec = CollectionSpec::documents(collection("names")) + .document_id_field("id") + .field(FieldSpec::new(property("label"), 0, FieldType::string(63))) + .index( + IndexSpec::new(index_name("by_label"), vec![path("label")]) + .unique(true) + .contested( + ContestedSpec::masternode_vote(vec![( + path("label"), + "^[a-z]{3,19}$".to_string(), + )]) + .description("short names are contested"), + ), + ); + let declaration = ContractDeclaration::new() + .collection(spec) + .rule(RuleSpec::guard( + collection("names"), + rule("label_present"), + vec![ActionScope::Create], + GuardExpr::Exists(FieldContext::New, path("label")), + )); + let manifest = expect_manifest(&declaration); + assert_eq!(manifest.rules.len(), 1); + assert!(manifest + .collection("names") + .unwrap() + .index("by_label") + .unwrap() + .contested + .is_some()); +} + +fn created_at_window() -> TimeRangeSpec { + TimeRangeSpec { + on: path("$createdAt"), + range_secs: 3600, + step_secs: 60, + phase_secs: 0, + ttl_secs: None, + } +} + +#[test] +fn should_carry_the_time_range_ttl_into_the_manifest() { + let window = TimeRangeSpec { + ttl_secs: Some(86_400), + ..created_at_window() + }; + let spec = minimal("c") + .requires(path("$createdAt")) + .index(IndexSpec::new(index_name("i"), vec![path("$createdAt")]).time_range(window)); + let manifest = expect_manifest(&ContractDeclaration::new().collection(spec)); + let index = manifest.collection("c").unwrap().index("i").unwrap(); + assert_eq!( + index.time_range.as_ref().and_then(|t| t.ttl_secs), + Some(86_400) + ); +} + +#[test] +fn should_report_time_range_source_not_first() { + let spec = minimal("c").requires(path("$createdAt")).index( + IndexSpec::new(index_name("i"), vec![path("a"), path("$createdAt")]) + .time_range(created_at_window()), + ); + let diagnostics = assert_reports( + &ContractDeclaration::new().collection(spec), + "TimeRangeSourceNotFirst", + ); + assert_eq!(diagnostics.len(), 1); +} + +#[test] +fn should_report_time_range_source_not_required() { + let spec = minimal("c").index( + IndexSpec::new(index_name("i"), vec![path("$createdAt")]).time_range(created_at_window()), + ); + let diagnostics = assert_reports( + &ContractDeclaration::new().collection(spec), + "TimeRangeSourceNotRequired", + ); + assert_eq!(diagnostics.len(), 1); +} + +#[test] +fn should_carry_required_system_properties_sorted_in_the_manifest() { + let spec = minimal("c") + .requires(path("$updatedAt")) + .requires(path("$createdAt")) + .index( + IndexSpec::new(index_name("i"), vec![path("$createdAt")]) + .time_range(created_at_window()), + ); + let manifest = expect_manifest(&ContractDeclaration::new().collection(spec)); + assert_eq!( + manifest.collection("c").unwrap().requires, + [path("$createdAt"), path("$updatedAt")] + ); +} + +#[test] +fn should_report_required_property_not_system_and_duplicate_required_property() { + let spec = minimal("c") + .requires(path("a")) + .requires(path("$createdAt")) + .requires(path("$createdAt")); + let diagnostics = expect_diagnostics(&ContractDeclaration::new().collection(spec)); + assert_eq!( + kinds(&diagnostics), + ["RequiredPropertyNotSystem", "DuplicateRequiredProperty"] + ); +} + +#[test] +fn should_report_terminal_not_a_property() { + let spec = CollectionSpec::documents(collection("likes")) + .document_id_field("id") + .mutable(false) + .index_only(true) + .field(FieldSpec::new(property("post"), 0, FieldType::identity())) + .index( + IndexSpec::new(index_name("by_post"), vec![path("post")]).index_only(IndexOnlySpec { + terminal: Some(path("$createdAt")), + preallocated: false, + skip_if_absent: false, + }), + ) + .index( + IndexSpec::new(index_name("by_post2"), vec![path("post")]).index_only(IndexOnlySpec { + terminal: Some(path("liker")), + preallocated: false, + skip_if_absent: false, + }), + ); + let diagnostics = expect_diagnostics(&ContractDeclaration::new().collection(spec)); + assert_eq!( + kinds(&diagnostics), + ["TerminalNotAProperty", "TerminalNotAProperty"] + ); +} + +#[test] +fn should_report_ranked_level_not_indexed() { + let spec = minimal("c").index( + IndexSpec::new(index_name("i"), vec![path("a")]) + .count() + .range_count(true) + .ranked_count_at(vec![path("missing")]), + ); + assert_reports( + &ContractDeclaration::new().collection(spec), + "RankedLevelNotIndexed", + ); +} + +#[test] +fn should_report_duplicate_ranked_level() { + let spec = minimal("c").index( + IndexSpec::new(index_name("i"), vec![path("a")]) + .count() + .range_count(true) + .ranked_count_at(vec![path("a"), path("a")]), + ); + let diagnostics = assert_reports( + &ContractDeclaration::new().collection(spec), + "DuplicateRankedLevel", + ); + assert_eq!(diagnostics.len(), 1); +} + +#[test] +fn should_report_index_only_option_on_stored_collection() { + let spec = minimal("c").index(IndexSpec::new(index_name("i"), vec![path("a")]).index_only( + IndexOnlySpec { + terminal: None, + preallocated: true, + skip_if_absent: false, + }, + )); + assert_reports( + &ContractDeclaration::new().collection(spec), + "IndexOnlyOptionOnStoredCollection", + ); +} + +#[test] +fn should_report_reference_collection_unknown_for_same_contract_permanent_documents() { + let spec = CollectionSpec::documents(collection("likes")) + .document_id_field("id") + .field(FieldSpec::new( + property("post"), + 0, + FieldType::Reference(ReferenceTarget::PermanentDocument { + contract: None, + document_type: collection("posts"), + agreement: vec![], + }), + )); + assert_reports( + &ContractDeclaration::new().collection(spec), + "ReferenceCollectionUnknown", + ); +} + +#[test] +fn should_report_reference_property_unknown_for_agreements_and_key_ids() { + let posts = CollectionSpec::documents(collection("posts")) + .document_id_field("id") + .deletable(false) + .field(FieldSpec::new(property("author"), 0, FieldType::identity())); + let likes = CollectionSpec::documents(collection("likes")) + .document_id_field("id") + .field(FieldSpec::new( + property("post"), + 0, + FieldType::Reference(ReferenceTarget::PermanentDocument { + contract: None, + document_type: collection("posts"), + agreement: vec![(path("missing_here"), path("missing_there"))], + }), + )) + .field(FieldSpec::new( + property("signer"), + 1, + FieldType::Reference(ReferenceTarget::IdentityPublicKey { + key_id_field: path("key_id"), + }), + )); + let diagnostics = expect_diagnostics( + &ContractDeclaration::new() + .collection(posts) + .collection(likes), + ); + assert_eq!( + kinds(&diagnostics), + [ + "ReferencePropertyUnknown", + "ReferencePropertyUnknown", + "ReferencePropertyUnknown" + ] + ); +} + +#[test] +fn should_resolve_nested_reference_paths_from_the_document_root() { + let posts = CollectionSpec::documents(collection("posts")) + .document_id_field("id") + .deletable(false) + .field(FieldSpec::new(property("author"), 0, FieldType::identity())); + let likes = CollectionSpec::documents(collection("likes")) + .document_id_field("id") + .field(FieldSpec::new(property("author"), 0, FieldType::identity())) + .field(FieldSpec::new( + property("nested"), + 1, + FieldType::Object(vec![ + FieldSpec::new(property("key_id"), 0, FieldType::integer(IntegerWidth::U32)), + FieldSpec::new( + property("signer"), + 1, + FieldType::Reference(ReferenceTarget::IdentityPublicKey { + key_id_field: path("nested.key_id"), + }), + ), + FieldSpec::new( + property("post"), + 2, + FieldType::Reference(ReferenceTarget::PermanentDocument { + contract: None, + document_type: collection("posts"), + agreement: vec![(path("author"), path("author"))], + }), + ), + ]), + )); + expect_manifest( + &ContractDeclaration::new() + .collection(posts) + .collection(likes), + ); +} + +#[test] +fn should_report_reference_property_unknown_for_a_sibling_relative_path() { + // Native resolves reference paths from the document root, so a nested + // field naming its sibling without the object prefix is unknown. + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new( + property("nested"), + 0, + FieldType::Object(vec![ + FieldSpec::new(property("key_id"), 0, FieldType::integer(IntegerWidth::U32)), + FieldSpec::new( + property("signer"), + 1, + FieldType::Reference(ReferenceTarget::IdentityPublicKey { + key_id_field: path("key_id"), + }), + ), + ]), + )); + let diagnostics = expect_diagnostics(&ContractDeclaration::new().collection(spec)); + assert_eq!(kinds(&diagnostics), ["ReferencePropertyUnknown"]); +} + +#[test] +fn should_canonicalize_agreement_order_and_report_duplicate_agreement_property() { + let posts = CollectionSpec::documents(collection("posts")) + .document_id_field("id") + .deletable(false) + .field(FieldSpec::new(property("a"), 0, FieldType::identity())) + .field(FieldSpec::new(property("b"), 1, FieldType::identity())); + let likes = |agreement: Vec<(PropertyPath, PropertyPath)>| { + CollectionSpec::documents(collection("likes")) + .document_id_field("id") + .field(FieldSpec::new(property("a"), 0, FieldType::identity())) + .field(FieldSpec::new(property("b"), 1, FieldType::identity())) + .field(FieldSpec::new( + property("post"), + 2, + FieldType::Reference(ReferenceTarget::PermanentDocument { + contract: None, + document_type: collection("posts"), + agreement, + }), + )) + }; + let forward = expect_manifest( + &ContractDeclaration::new() + .collection(posts.clone()) + .collection(likes(vec![(path("a"), path("a")), (path("b"), path("b"))])), + ); + let backward = expect_manifest( + &ContractDeclaration::new() + .collection(posts.clone()) + .collection(likes(vec![(path("b"), path("b")), (path("a"), path("a"))])), + ); + assert_eq!(forward, backward); + let diagnostics = expect_diagnostics( + &ContractDeclaration::new() + .collection(posts) + .collection(likes(vec![(path("a"), path("a")), (path("a"), path("b"))])), + ); + assert_eq!(kinds(&diagnostics), ["DuplicateAgreementProperty"]); +} + +#[test] +fn should_accept_a_cross_contract_permanent_document_reference_without_checking_it() { + let likes = CollectionSpec::documents(collection("likes")) + .document_id_field("id") + .field(FieldSpec::new( + property("post"), + 0, + FieldType::Reference(ReferenceTarget::PermanentDocument { + contract: Some([7; 32]), + document_type: collection("posts"), + agreement: vec![], + }), + )); + expect_manifest(&ContractDeclaration::new().collection(likes)); +} + +// Collection kinds + +#[test] +fn should_report_singleton_with_indexes() { + let spec = CollectionSpec::singleton(collection("config")) + .field(FieldSpec::new(property("a"), 0, FieldType::Bool)) + .index(IndexSpec::new(index_name("i"), vec![path("a")])); + assert_reports( + &ContractDeclaration::new().collection(spec), + "SingletonWithIndexes", + ); +} + +#[test] +fn should_report_singleton_with_document_id_field() { + let spec = CollectionSpec::singleton(collection("config")) + .document_id_field("id") + .field(FieldSpec::new(property("a"), 0, FieldType::Bool)); + assert_reports( + &ContractDeclaration::new().collection(spec), + "SingletonWithDocumentIdField", + ); +} + +#[test] +fn should_report_missing_document_id_field() { + let spec = CollectionSpec::documents(collection("c")).field(FieldSpec::new( + property("a"), + 0, + FieldType::Bool, + )); + assert_reports( + &ContractDeclaration::new().collection(spec), + "MissingDocumentIdField", + ); +} + +#[test] +fn should_accept_a_singleton_receiver_without_a_document_id_on_the_wire() { + let config = CollectionSpec::singleton(collection("config")).field(FieldSpec::new( + property("a"), + 0, + FieldType::Bool, + )); + let declaration = ContractDeclaration::new().collection(config).entry( + EntrySpec::new(method("config.set")) + .receiver(Receiver::Mut(collection("config"))) + .param("a", ValueType::Bool), + ); + let manifest = expect_manifest(&declaration); + assert!(!manifest.method("config.set").unwrap().takes_document_id); +} + +// Entries + +#[test] +fn should_report_receiver_collection_unknown() { + let declaration = ContractDeclaration::new() + .entry(EntrySpec::new(method("f")).receiver(Receiver::Ref(collection("missing")))); + assert_reports(&declaration, "ReceiverCollectionUnknown"); +} + +#[test] +fn should_report_mutable_receiver_on_immutable_collection() { + let declaration = ContractDeclaration::new() + .collection(minimal("frozen").mutable(false)) + .entry(EntrySpec::new(method("f")).receiver(Receiver::Mut(collection("frozen")))); + assert_reports(&declaration, "MutableReceiverOnImmutableCollection"); + let declaration = ContractDeclaration::new() + .collection(minimal("frozen").mutable(false)) + .entry(EntrySpec::new(method("f")).receiver(Receiver::Ref(collection("frozen")))); + expect_manifest(&declaration); +} + +#[test] +fn should_report_read_only_entry_with_mutable_receiver() { + let declaration = ContractDeclaration::new().collection(minimal("c")).entry( + EntrySpec::new(method("f")) + .receiver(Receiver::Mut(collection("c"))) + .read_only(true), + ); + assert_reports(&declaration, "ReadOnlyEntryWithMutableReceiver"); +} + +#[test] +fn should_report_duplicate_parameter() { + let declaration = ContractDeclaration::new().entry( + EntrySpec::new(method("f")) + .param("a", ValueType::Bool) + .param("a", ValueType::Bool), + ); + assert_reports(&declaration, "DuplicateParameter"); +} + +#[test] +fn should_report_entry_module_unknown() { + let declaration = ContractDeclaration::new() + .module(ModuleSpec::new(module("main"))) + .entry(EntrySpec::new(method("f")).module(module("helpers"))); + assert_reports(&declaration, "EntryModuleUnknown"); +} + +#[test] +fn should_report_entry_module_required_with_several_modules() { + let declaration = ContractDeclaration::new() + .module(ModuleSpec::new(module("main"))) + .module(ModuleSpec::new(module("helpers"))) + .entry(EntrySpec::new(method("f"))); + assert_reports(&declaration, "EntryModuleRequired"); +} + +// Modules + +#[test] +fn should_report_interface_provider_unknown() { + let declaration = ContractDeclaration::new() + .module(ModuleSpec::new(module("main"))) + .interface(InterfaceSpec::new(interface("math"), module("ghost"))); + assert_reports(&declaration, "InterfaceProviderUnknown"); +} + +#[test] +fn should_report_used_interface_unknown() { + let declaration = + ContractDeclaration::new().module(ModuleSpec::new(module("main")).uses(interface("math"))); + assert_reports(&declaration, "UsedInterfaceUnknown"); +} + +#[test] +fn should_report_module_self_import() { + let declaration = ContractDeclaration::new() + .module(ModuleSpec::new(module("main")).uses(interface("math"))) + .interface(InterfaceSpec::new(interface("math"), module("main"))); + assert_reports(&declaration, "ModuleSelfImport"); +} + +#[test] +fn should_report_module_graph_cycle() { + let declaration = ContractDeclaration::new() + .module(ModuleSpec::new(module("a")).uses(interface("from_b"))) + .module(ModuleSpec::new(module("b")).uses(interface("from_c"))) + .module(ModuleSpec::new(module("c")).uses(interface("from_a"))) + .interface(InterfaceSpec::new(interface("from_a"), module("a"))) + .interface(InterfaceSpec::new(interface("from_b"), module("b"))) + .interface(InterfaceSpec::new(interface("from_c"), module("c"))); + let diagnostics = assert_reports(&declaration, "ModuleGraphCycle"); + let DiagnosticKind::ModuleGraphCycle { modules } = &diagnostics[0].kind else { + panic!() + }; + assert_eq!(modules.len(), 3); +} + +#[test] +fn should_report_duplicate_interface_function() { + let declaration = ContractDeclaration::new() + .module(ModuleSpec::new(module("main"))) + .interface( + InterfaceSpec::new(interface("math"), module("main")) + .function("add", vec![], ValueType::Unit) + .function("add", vec![], ValueType::Bool), + ); + assert_reports(&declaration, "DuplicateInterfaceFunction"); +} + +#[test] +fn should_report_unbounded_field_in_interface_parameters_and_returns() { + let declaration = ContractDeclaration::new() + .module(ModuleSpec::new(module("main"))) + .interface( + InterfaceSpec::new(interface("text"), module("main")).function( + "join", + vec![ + ParamSpec { + name: "parts".to_string(), + ty: ValueType::list( + 8, + ValueType::Struct(vec![( + "text".to_string(), + ValueType::String { max_chars: None }, + )]), + ), + }, + ParamSpec { + name: "separator".to_string(), + ty: ValueType::string(4), + }, + ], + ValueType::option(ValueType::Bytes { max_len: None }), + ), + ); + let diagnostics = expect_diagnostics(&declaration); + assert_eq!(kinds(&diagnostics), ["UnboundedField", "UnboundedField"]); + assert_eq!( + diagnostics[0].path().to_string(), + "interface text, function join, parameter parts, member [].text" + ); + assert_eq!( + diagnostics[1].path().to_string(), + "interface text, function join, parameter return" + ); +} + +#[test] +fn should_point_nested_wire_diagnostics_at_the_member() { + let profile = ValueType::Struct(vec![ + ("name".to_string(), ValueType::String { max_chars: None }), + ("avatar".to_string(), ValueType::Bytes { max_len: None }), + ( + "tags".to_string(), + ValueType::list(4, ValueType::String { max_chars: None }), + ), + ]); + let declaration = ContractDeclaration::new() + .module(ModuleSpec::new(module("main"))) + .interface( + InterfaceSpec::new(interface("text"), module("main")).function( + "describe", + vec![], + ValueType::option(profile.clone()), + ), + ) + .entry( + EntrySpec::new(method("f")) + .module(module("main")) + .param("profile", profile), + ); + let diagnostics = expect_diagnostics(&declaration); + let paths: Vec = diagnostics.iter().map(|d| d.path().to_string()).collect(); + assert_eq!( + paths, + [ + "interface text, function describe, parameter return, member name", + "interface text, function describe, parameter return, member avatar", + "interface text, function describe, parameter return, member tags.[]", + "entry f, parameter profile, member name", + "entry f, parameter profile, member avatar", + "entry f, parameter profile, member tags.[]", + ] + ); + assert!(diagnostics + .iter() + .all(|d| d.kind.name() == "UnboundedField")); +} + +#[test] +fn should_bind_a_dag_of_modules_and_sort_bindings() { + let declaration = ContractDeclaration::new() + .module( + ModuleSpec::new(module("main")) + .uses(interface("math")) + .uses(interface("text")), + ) + .module(ModuleSpec::new(module("helpers")).uses(interface("text"))) + .module(ModuleSpec::new(module("core"))) + .interface(InterfaceSpec::new(interface("math"), module("helpers"))) + .interface(InterfaceSpec::new(interface("text"), module("core"))) + .entry(EntrySpec::new(method("f")).module(module("main"))); + let manifest = expect_manifest(&declaration); + let bindings: Vec<(&str, &str, &str)> = manifest + .modules + .bindings + .iter() + .map(|b| { + ( + b.importer.as_str(), + b.provider.as_str(), + b.interface.as_str(), + ) + }) + .collect(); + assert_eq!( + bindings, + [ + ("helpers", "core", "text"), + ("main", "core", "text"), + ("main", "helpers", "math") + ] + ); + assert!(manifest + .capabilities + .requires(CapabilityRequirement::Modules)); +} + +// Rules + +#[test] +fn should_report_predicate_module_unknown() { + let declaration = + ContractDeclaration::new() + .collection(minimal("c")) + .rule(RuleSpec::predicate( + collection("c"), + rule("p"), + vec![ActionScope::Create], + module("ghost"), + "check", + )); + assert_reports(&declaration, "PredicateModuleUnknown"); +} + +#[test] +fn should_report_rule_collection_unknown() { + let declaration = ContractDeclaration::new().rule(RuleSpec::guard( + collection("ghost"), + rule("r"), + vec![ActionScope::Create], + GuardExpr::boolean(true), + )); + assert_reports(&declaration, "RuleCollectionUnknown"); +} + +#[test] +fn should_report_rule_without_actions() { + let declaration = ContractDeclaration::new() + .collection(minimal("c")) + .rule(RuleSpec::guard( + collection("c"), + rule("r"), + vec![], + GuardExpr::boolean(true), + )); + assert_reports(&declaration, "RuleWithoutActions"); +} + +#[test] +fn should_report_guard_field_unknown() { + let declaration = ContractDeclaration::new() + .collection(minimal("c")) + .rule(RuleSpec::guard( + collection("c"), + rule("r"), + vec![ActionScope::Replace], + GuardExpr::field(FieldContext::New, path("points")) + .ge(GuardExpr::field(FieldContext::Old, path("a"))), + )); + let diagnostics = assert_reports(&declaration, "GuardFieldUnknown"); + assert_eq!(diagnostics.len(), 1); +} + +#[test] +fn should_sort_and_dedupe_rule_actions() { + let declaration = ContractDeclaration::new() + .collection(minimal("c")) + .rule(RuleSpec::guard( + collection("c"), + rule("r"), + vec![ + ActionScope::Delete, + ActionScope::Create, + ActionScope::Delete, + ], + GuardExpr::boolean(true), + )); + let manifest = expect_manifest(&declaration); + assert_eq!( + manifest.rules[0].actions, + [ActionScope::Create, ActionScope::Delete] + ); + assert!(manifest + .capabilities + .requires(CapabilityRequirement::NativeGuards)); +} + +// Capabilities + +#[test] +fn should_report_capability_interface_disabled_for_the_private_store() { + let declaration = + ContractDeclaration::new().collection(minimal("secrets").store(Store::Private)); + let diagnostics = expect_diagnostics(&declaration); + assert_eq!(kinds(&diagnostics), ["CapabilityInterfaceDisabled"]); + let DiagnosticKind::CapabilityInterfaceDisabled { requirement } = &diagnostics[0].kind else { + panic!() + }; + assert_eq!(*requirement, CapabilityRequirement::PrivateStore); +} + +#[test] +fn should_report_capability_not_declarable_for_derived_requirements() { + let declaration = ContractDeclaration::new().require(CapabilityRequirement::ContractWrites); + assert_reports(&declaration, "CapabilityNotDeclarable"); +} + +#[test] +fn should_derive_typed_collection_and_predicate_requirements() { + let declaration = ContractDeclaration::new() + .collection(minimal("c")) + .typed_collection(TypedCollectionSpec::new( + collection("totals"), + TypedCollectionKind::Sum, + ValueType::Identifier, + ValueType::Integer(IntegerWidth::I64), + )) + .rule(RuleSpec::predicate( + collection("c"), + rule("p"), + vec![ActionScope::Create], + module("main"), + "check", + )) + .receipts(ReceiptPolicy::Disabled); + let manifest = expect_manifest(&declaration); + assert!(manifest + .capabilities + .requires(CapabilityRequirement::TypedCollections( + TypedCollectionKind::Sum + ))); + assert!(manifest + .capabilities + .requires(CapabilityRequirement::WasmPredicates)); + assert!(!manifest + .capabilities + .requires(CapabilityRequirement::StoredReceipts)); + assert_eq!(manifest.typed_collections.len(), 1); +} + +// Sugar + +#[test] +fn should_expand_average_sugar_into_count_and_sum_at_both_levels() { + let longhand = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new(property("class"), 0, FieldType::string(8))) + .field(FieldSpec::new( + property("points"), + 1, + FieldType::integer(IntegerWidth::I64), + )) + .count(true) + .range_count(true) + .sum(property("points")) + .range_sum(true) + .index( + IndexSpec::new(index_name("i"), vec![path("class")]) + .count() + .range_count(true) + .sum(property("points")) + .range_sum(true), + ); + let sugar = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new(property("class"), 0, FieldType::string(8))) + .field(FieldSpec::new( + property("points"), + 1, + FieldType::integer(IntegerWidth::I64), + )) + .average(property("points")) + .range_average(true) + .index( + IndexSpec::new(index_name("i"), vec![path("class")]) + .average(property("points")) + .range_average(true), + ); + let a = expect_manifest(&ContractDeclaration::new().collection(longhand)); + let b = expect_manifest(&ContractDeclaration::new().collection(sugar)); + assert_eq!(a, b); + let c = a.collection("c").unwrap(); + assert!(c.count && c.range_count && c.range_sum); + assert_eq!(c.sum, Some(property("points"))); +} + +#[test] +fn should_keep_offset_count_when_average_sugar_is_added() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new(property("class"), 0, FieldType::string(8))) + .field(FieldSpec::new( + property("points"), + 1, + FieldType::integer(IntegerWidth::I64), + )) + .index( + IndexSpec::new(index_name("i"), vec![path("class")]) + .count_allowing_offset() + .average(property("points")), + ); + let manifest = expect_manifest(&ContractDeclaration::new().collection(spec)); + assert_eq!( + manifest.collection("c").unwrap().index("i").unwrap().count, + Countability::CountableAllowingOffset + ); +} + +#[test] +fn should_report_conflicting_option_for_average_against_a_different_sum() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new( + property("a"), + 0, + FieldType::integer(IntegerWidth::I64), + )) + .field(FieldSpec::new( + property("b"), + 1, + FieldType::integer(IntegerWidth::I64), + )) + .sum(property("a")) + .average(property("b")) + .index( + IndexSpec::new(index_name("i"), vec![path("a")]) + .sum(property("a")) + .average(property("b")), + ); + let diagnostics = expect_diagnostics(&ContractDeclaration::new().collection(spec)); + assert_eq!( + kinds(&diagnostics), + ["ConflictingOption", "ConflictingOption"] + ); +} + +#[test] +fn should_report_conflicting_option_for_explicit_false_next_to_average_sugar() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new(property("class"), 0, FieldType::string(8))) + .field(FieldSpec::new( + property("points"), + 1, + FieldType::integer(IntegerWidth::I64), + )) + .count(false) + .range_count(false) + .range_sum(false) + .average(property("points")) + .range_average(true) + .index( + IndexSpec::new(index_name("i"), vec![path("class")]) + .countability(Countability::NotCountable) + .range_count(false) + .range_sum(false) + .average(property("points")) + .range_average(true), + ); + let diagnostics = expect_diagnostics(&ContractDeclaration::new().collection(spec)); + assert_eq!( + kinds(&diagnostics), + [ + "ConflictingOption", + "ConflictingOption", + "ConflictingOption", + "ConflictingOption", + "ConflictingOption", + "ConflictingOption" + ] + ); + let pairs: Vec> = diagnostics + .iter() + .map(|d| match &d.kind { + DiagnosticKind::ConflictingOption { options, .. } => options.clone(), + _ => panic!(), + }) + .collect(); + assert_eq!(pairs[0], ["average", "count"]); + assert_eq!(pairs[1], ["range_average", "range_count"]); + assert_eq!(pairs[2], ["range_average", "range_sum"]); + assert_eq!(pairs[3], ["average", "count"]); +} + +#[test] +fn should_promote_omitted_options_but_keep_explicit_true_next_to_average_sugar() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new(property("class"), 0, FieldType::string(8))) + .field(FieldSpec::new( + property("points"), + 1, + FieldType::integer(IntegerWidth::I64), + )) + .count(true) + .average(property("points")) + .index( + IndexSpec::new(index_name("i"), vec![path("class")]) + .range_count(true) + .average(property("points")) + .range_average(true), + ); + let manifest = expect_manifest(&ContractDeclaration::new().collection(spec)); + let c = manifest.collection("c").unwrap(); + assert!(c.count && !c.range_count && !c.range_sum); + let i = c.index("i").unwrap(); + assert_eq!(i.count, Countability::Countable); + assert!(i.range_count && i.range_sum); +} + +#[test] +fn should_report_conflicting_option_for_range_average_without_average() { + let spec = minimal("c") + .range_average(true) + .index(IndexSpec::new(index_name("i"), vec![path("a")]).range_average(true)); + let diagnostics = expect_diagnostics(&ContractDeclaration::new().collection(spec)); + assert_eq!( + kinds(&diagnostics), + ["ConflictingOption", "ConflictingOption"] + ); +} + +// Order independence and identity stability + +fn reversed(declaration: &ContractDeclaration) -> ContractDeclaration { + let mut reversed = declaration.clone(); + reversed.modules.reverse(); + reversed.interfaces.reverse(); + reversed.collections.reverse(); + reversed.typed_collections.reverse(); + reversed.entries.reverse(); + reversed.rules.reverse(); + reversed.capabilities.reverse(); + for collection in &mut reversed.collections { + collection.fields.reverse(); + collection.indexes.reverse(); + collection.token_costs.reverse(); + for index in &mut collection.indexes { + if let RankedCount::At(levels) = &mut index.ranked.count { + levels.reverse(); + } + if let Some(contested) = &mut index.contested { + contested.field_matches.reverse(); + } + } + } + for rule in &mut reversed.rules { + rule.actions.reverse(); + } + for module in &mut reversed.modules { + module.uses.reverse(); + } + reversed +} + +fn rich_declaration() -> ContractDeclaration { + let scores = score_collection(WritePolicy::Owner) + .token_cost(ActionScope::Create, TokenCost::new(0, 5)) + .token_cost(ActionScope::Delete, TokenCost::new(1, 3)) + .index( + IndexSpec::new(index_name("prefix"), vec![path("class"), path("owner")]) + .count() + .range_count(true) + .ranked_count_at(vec![path("owner"), path("class")]) + .contested(ContestedSpec::masternode_vote(vec![ + (path("owner"), "^a".to_string()), + (path("class"), "^b".to_string()), + ])), + ); + let mut declaration = ContractDeclaration::new() + .module( + ModuleSpec::new(module("main")) + .uses(interface("math")) + .uses(interface("text")), + ) + .module(ModuleSpec::new(module("helpers"))) + .interface(InterfaceSpec::new(interface("math"), module("helpers"))) + .interface(InterfaceSpec::new(interface("text"), module("helpers"))) + .collection(scores) + .collection(minimal("audit")) + .typed_collection(TypedCollectionSpec::new( + collection("totals"), + TypedCollectionKind::Sum, + ValueType::Identifier, + ValueType::Integer(IntegerWidth::I64), + )) + .rule(RuleSpec::guard( + collection("scores"), + rule("monotonic"), + vec![ActionScope::Replace, ActionScope::Create], + GuardExpr::field(FieldContext::New, path("points")) + .ge(GuardExpr::field(FieldContext::Old, path("points"))), + )) + .rule(RuleSpec::guard( + collection("scores"), + rule("bounded"), + vec![ActionScope::Create], + GuardExpr::field(FieldContext::New, path("points")).le(GuardExpr::integer(100)), + )) + .rule(RuleSpec::predicate( + collection("scores"), + rule("checked"), + vec![ActionScope::Delete], + module("helpers"), + "may_delete", + )) + .require(CapabilityRequirement::Randomness) + .require(CapabilityRequirement::Acl); + for entry in score_entries() { + declaration = declaration.entry(entry.module(module("main"))); + } + declaration +} + +#[test] +fn should_produce_the_same_manifest_regardless_of_declaration_order() { + let declaration = rich_declaration(); + let forward = expect_manifest(&declaration); + let backward = expect_manifest(&reversed(&declaration)); + assert_eq!(forward, backward); + let rules: Vec<&str> = forward.rules.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(rules, ["bounded", "checked", "monotonic"]); + let costs: Vec = forward + .collection("scores") + .unwrap() + .token_costs + .iter() + .map(|c| c.action) + .collect(); + assert_eq!(costs, [ActionScope::Create, ActionScope::Delete]); +} + +#[test] +fn should_produce_the_same_manifest_regardless_of_attribute_or_builder_origin() { + let declaration = rich_declaration(); + let mut swapped = declaration.clone(); + for collection in &mut swapped.collections { + collection.origin = DeclarationOrigin::Builder; + for index in &mut collection.indexes { + index.origin = DeclarationOrigin::Builder; + } + } + for entry in &mut swapped.entries { + entry.origin = DeclarationOrigin::Builder; + } + assert_eq!(expect_manifest(&declaration), expect_manifest(&swapped)); +} + +#[test] +fn should_keep_method_identity_when_an_entry_moves_between_modules() { + let declaration = rich_declaration(); + let before = expect_manifest(&declaration); + let mut moved = declaration.clone(); + for entry in &mut moved.entries { + if entry.name.as_str() == "score.total" { + entry.module = Some(module("helpers")); + } + } + let after = expect_manifest(&moved); + let names_before: Vec<&MethodName> = before.methods.names().collect(); + let names_after: Vec<&MethodName> = after.methods.names().collect(); + assert_eq!(names_before, names_after); + for (a, b) in before.methods.entries.iter().zip(&after.methods.entries) { + assert_eq!(a.name, b.name); + assert_eq!(a.export, b.export); + assert_eq!(a.params, b.params); + assert_eq!(a.returns, b.returns); + if a.name.as_str() == "score.total" { + assert_eq!(a.module.as_str(), "main"); + assert_eq!(b.module.as_str(), "helpers"); + } else { + assert_eq!(a.module, b.module); + } + } + let mut after_rebound = after.clone(); + for entry in &mut after_rebound.methods.entries { + if entry.name.as_str() == "score.total" { + entry.module = module("main"); + } + } + assert_eq!(before, after_rebound); +} + +#[test] +fn should_collect_every_diagnostic_instead_of_stopping_at_the_first() { + let declaration = ContractDeclaration::new() + .collection(minimal("c").schema_revision(0)) + .entry(EntrySpec::new(method("f")).receiver(Receiver::Ref(collection("ghost")))) + .rule(RuleSpec::guard( + collection("ghost"), + rule("r"), + vec![], + GuardExpr::boolean(true), + )); + let diagnostics = expect_diagnostics(&declaration); + assert_eq!( + kinds(&diagnostics), + [ + "InvalidSchemaRevision", + "ReceiverCollectionUnknown", + "RuleCollectionUnknown", + "RuleWithoutActions" + ] + ); +} diff --git a/packages/rs-dash-sdk-contract/tests/alloc_profile.rs b/packages/rs-dash-sdk-contract/tests/alloc_profile.rs new file mode 100644 index 00000000000..495ce41fed4 --- /dev/null +++ b/packages/rs-dash-sdk-contract/tests/alloc_profile.rs @@ -0,0 +1,63 @@ +//! The declaration model on the guest profile: this test is compiled by the +//! CI guest cut with `--no-default-features`, so it exercises the builders, +//! the validator and the manifest without `std`. Under default features it +//! runs the same code with `std` present. + +use dash_sdk_contract::prelude::*; + +fn scores() -> CollectionSpec { + CollectionSpec::documents(CollectionName::new("scores").unwrap()) + .document_id_field("id") + .field(FieldSpec::new( + PropertyName::new("class").unwrap(), + 0, + FieldType::string(64), + )) + .field(FieldSpec::new( + PropertyName::new("points").unwrap(), + 1, + FieldType::bounded_integer(IntegerWidth::I64, 0, 1_000_000), + )) + .index( + IndexSpec::new( + IndexName::new("by_class").unwrap(), + vec![PropertyPath::new("class").unwrap()], + ) + .count() + .sum(PropertyName::new("points").unwrap()), + ) +} + +#[test] +fn should_build_the_sketch_manifest_through_builders() { + let declaration = ContractDeclaration::new().collection(scores()).entry( + EntrySpec::new(MethodName::new("score.add").unwrap()) + .receiver(Receiver::Mut(CollectionName::new("scores").unwrap())) + .param("delta", ValueType::Integer(IntegerWidth::I64)), + ); + let manifest = validate(&declaration).expect("the sketch validates"); + assert_eq!(manifest.collections().len(), 1); + let add = manifest.method("score.add").expect("entry present"); + assert_eq!(add.export, entry_export_symbol(&add.name)); + assert_eq!( + StagingPoint::for_receiver(&add.receiver), + Some(StagingPoint::MutReceiverOnOk) + ); +} + +#[test] +fn should_report_diagnostics_with_stable_codes() { + let declaration = ContractDeclaration::new() + .collection(scores().mutable(false)) + .entry( + EntrySpec::new(MethodName::new("score.add").unwrap()) + .receiver(Receiver::Mut(CollectionName::new("scores").unwrap())), + ); + let diagnostics = validate(&declaration).expect_err("immutable receiver is rejected"); + assert_eq!(diagnostics.len(), 1); + assert_eq!( + diagnostics[0].kind.name(), + "MutableReceiverOnImmutableCollection" + ); + assert!(diagnostics[0].code().starts_with("DSC")); +} diff --git a/packages/rs-dash-sdk-contract/tests/book_table.rs b/packages/rs-dash-sdk-contract/tests/book_table.rs new file mode 100644 index 00000000000..bc697054813 --- /dev/null +++ b/packages/rs-dash-sdk-contract/tests/book_table.rs @@ -0,0 +1,100 @@ +//! The grammar table in the book chapter is checked against `ATTRIBUTES`: +//! every attribute row names exactly the options the grammar declares, in +//! grammar order, so an edit to either side without the other fails here. +//! +//! The chapter is read from the repository at test time, so this test runs +//! only where the repository layout exists (it is skipped when the book file +//! is absent, for example in a published crate). + +#![cfg(feature = "std")] + +use dash_sdk_contract::grammar::ATTRIBUTES; + +const CHAPTER: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../book/src/dashvm/contract-declarations.md" +); + +/// Option names as they appear in a table cell: every backtick span at +/// parenthesis depth zero whose first character starts an identifier, keeping +/// the identifier only (`sum = "

"` yields `sum`, `fields( = "asc", +/// ...)` yields `fields`). Spans inside parentheses are the values an option +/// admits (`(`any` / `owner`)`), not options, which is the table's convention. +fn options_in_cell(cell: &str) -> Vec { + let mut options = Vec::new(); + let mut depth = 0usize; + let mut chars = cell.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + '`' => { + let mut span = String::new(); + for c in chars.by_ref() { + if c == '`' { + break; + } + span.push(c); + } + let identifier: String = span + .chars() + .take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '_') + .collect(); + let starts_identifier = span.chars().next().is_some_and(|c| c.is_ascii_lowercase()); + if depth == 0 && starts_identifier && !options.contains(&identifier) { + options.push(identifier); + } + } + _ => {} + } + } + options +} + +fn table_rows(chapter: &str) -> Vec<(String, Vec)> { + chapter + .lines() + .filter(|line| line.starts_with("| `")) + .filter_map(|line| { + let cells: Vec<&str> = line.trim_matches('|').split(" | ").collect(); + let [name, _target, options] = cells.as_slice() else { + return None; + }; + let name = name.trim().trim_matches('`').to_string(); + Some((name, options_in_cell(options))) + }) + .collect() +} + +#[test] +fn should_keep_the_book_grammar_table_equal_to_the_grammar() { + let Ok(chapter) = std::fs::read_to_string(CHAPTER) else { + eprintln!("book chapter not found at {CHAPTER}; skipping"); + return; + }; + let rows = table_rows(&chapter); + let attribute_rows: Vec<&(String, Vec)> = rows + .iter() + .filter(|(name, _)| ATTRIBUTES.iter().any(|spec| spec.name == name)) + .collect(); + assert_eq!( + attribute_rows.len(), + ATTRIBUTES.len(), + "the book table has {} attribute rows, the grammar {}", + attribute_rows.len(), + ATTRIBUTES.len() + ); + for (spec, (name, options)) in ATTRIBUTES.iter().zip(attribute_rows) { + assert_eq!( + spec.name, name, + "attribute row order differs from the grammar" + ); + let declared: Vec<&str> = spec.keys.iter().map(|key| key.name).collect(); + // `rule` spells its exclusive pair as prose ("exactly one of"), the + // identifiers still appear in grammar order. + assert_eq!( + options, &declared, + "options of `{name}` in the book differ from the grammar" + ); + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 0835736b9de..e5fb32b4eb5 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -2,4 +2,4 @@ # Rust version the same as in /README.md channel = "1.98.1" -targets = ["wasm32-unknown-unknown"] +targets = ["wasm32-unknown-unknown", "wasm32v1-none"]