diff --git a/crates/thing-contract/src/asset.rs b/crates/thing-contract/src/asset.rs index 575b212..9e97d7e 100644 --- a/crates/thing-contract/src/asset.rs +++ b/crates/thing-contract/src/asset.rs @@ -2,6 +2,7 @@ use cultcache_rs::{CultSlots, DatabaseEntry}; +use crate::domain::{Count, Segment, Version}; use crate::keys::{ProviderId, asset_catalog_key}; #[derive(Clone, Debug, PartialEq, CultSlots)] @@ -13,9 +14,9 @@ pub struct AssetEntry { #[cultcache(key = 2)] pub uri: String, #[cultcache(key = 3)] - pub width: i32, + pub width: Count, #[cultcache(key = 4)] - pub height: i32, + pub height: Count, } #[derive(Clone, Debug, PartialEq, DatabaseEntry)] @@ -24,9 +25,9 @@ pub struct AssetCatalog { #[cultcache(key = 0)] pub provider_id: ProviderId, #[cultcache(key = 1)] - pub catalog_id: String, + pub catalog_id: Segment, #[cultcache(key = 2)] - pub version: i64, + pub version: Version, #[cultcache(key = 3)] pub assets: Vec, } diff --git a/crates/thing-contract/src/command.rs b/crates/thing-contract/src/command.rs index 66344d9..6db6a88 100644 --- a/crates/thing-contract/src/command.rs +++ b/crates/thing-contract/src/command.rs @@ -3,6 +3,7 @@ use cultcache_rs::{CultSlots, CultUnion, DatabaseEntry}; +use crate::domain::Version; use crate::keys::{CommandId, NodeId, SchemaId, invocation_key}; use crate::source::ParamName; @@ -87,7 +88,9 @@ pub struct CapturedInput { pub value: InputValue, } -/// A consumer's request that a provider run a declared command. +/// A consumer's request that a provider run a declared command. Its fields are public, so a +/// result's copy of `invocation_id` is a convenience and not provenance: core binds results +/// to the invocations it issued. #[derive(Clone, Debug, PartialEq, DatabaseEntry)] #[cultcache(type = "gamecult.thing.command_invocation", schema = "gamecult.thing.command_invocation.v1")] pub struct CommandInvocation { @@ -97,7 +100,7 @@ pub struct CommandInvocation { #[cultcache(key = 1)] pub surface_key: String, #[cultcache(key = 2)] - pub surface_version: i64, + pub surface_version: Version, #[cultcache(key = 3)] pub command: CommandId, #[cultcache(key = 4)] diff --git a/crates/thing-contract/src/domain.rs b/crates/thing-contract/src/domain.rs new file mode 100644 index 0000000..59e26b0 --- /dev/null +++ b/crates/thing-contract/src/domain.rs @@ -0,0 +1,216 @@ +//! The value domains of the contract's numbers and local names. Each domain is one field +//! type, and `new` is its only door: constructors, struct literals and every serde +//! `Deserializer` reach a value through it, so a value out of domain cannot exist. +//! +//! Each type writes and reads exactly the leaf of its inner type (`Int32`, `Int64`, +//! `String`), so the bytes are the plain leaf's. The reference's unsigned leaves are absent +//! from the TypeScript and Python packages, which is why the bound is a checked type here +//! and a refusal golden there. + +use std::fmt; + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; + +/// A value was outside its domain. Names the domain and its bound, never the value. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DomainError { + domain: &'static str, + bound: &'static str, +} + +impl DomainError { + const fn new(domain: &'static str, bound: &'static str) -> Self { + Self { domain, bound } + } + + /// The type that refused. + pub fn domain(&self) -> &'static str { + self.domain + } + + /// The bound that was not met. + pub fn bound(&self) -> &'static str { + self.bound + } +} + +impl fmt::Display for DomainError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} must be {}", self.domain, self.bound) + } +} + +impl std::error::Error for DomainError {} + +/// A result's position in its invocation's answers, counting from 1. `new` is the only door: +/// +/// ``` +/// use thing_contract::Seq; +/// assert!(Seq::new(1).is_ok()); +/// ``` +/// +/// ```compile_fail,E0423 +/// use thing_contract::Seq; +/// let _ = Seq(0); +/// ``` +/// +/// ```compile_fail,E0616 +/// use thing_contract::Seq; +/// let mut seq = Seq::FIRST; +/// seq.0 = 0; +/// ``` +/// +/// ```compile_fail,E0599 +/// use thing_contract::Seq; +/// let _ = Seq::default(); +/// ``` +/// +/// ```compile_fail,E0277 +/// use thing_contract::Seq; +/// let _ = Seq::from(0); +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Seq(i32); + +impl Seq { + pub const FIRST: Seq = Seq(1); + + pub fn new(value: i32) -> Result { + if value >= 1 { Ok(Self(value)) } else { Err(DomainError::new("Seq", "at least 1")) } + } + + pub fn get(self) -> i32 { + self.0 + } + + /// The following position; stays at `i32::MAX` rather than leaving the domain. + pub fn next(self) -> Seq { + Seq(self.0.saturating_add(1)) + } +} + +/// A document or source version, never negative. `new` is the only door: +/// +/// ``` +/// use thing_contract::Version; +/// assert!(Version::new(0).is_ok()); +/// ``` +/// +/// ```compile_fail,E0423 +/// use thing_contract::Version; +/// let _ = Version(-1); +/// ``` +/// +/// ```compile_fail,E0599 +/// use thing_contract::Version; +/// let _ = Version::default(); +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Version(i64); + +impl Version { + pub fn new(value: i64) -> Result { + if value >= 0 { Ok(Self(value)) } else { Err(DomainError::new("Version", "at least 0")) } + } + + pub fn get(self) -> i64 { + self.0 + } +} + +/// A count, an extent or an index, never negative. `new` is the only door: +/// +/// ``` +/// use thing_contract::Count; +/// assert!(Count::new(0).is_ok()); +/// ``` +/// +/// ```compile_fail,E0423 +/// use thing_contract::Count; +/// let _ = Count(-1); +/// ``` +/// +/// ```compile_fail,E0277 +/// use thing_contract::Count; +/// let _: Count = (-1).into(); +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Count(i32); + +impl Count { + pub fn new(value: i32) -> Result { + if value >= 0 { Ok(Self(value)) } else { Err(DomainError::new("Count", "at least 0")) } + } + + pub fn get(self) -> i32 { + self.0 + } +} + +/// A name the contract joins into a record key: non-empty, no `.`. `new` is the only door: +/// +/// ``` +/// use thing_contract::Segment; +/// assert!(Segment::new("main").is_ok()); +/// ``` +/// +/// ```compile_fail,E0423 +/// use thing_contract::Segment; +/// let _ = Segment(String::from("a.b")); +/// ``` +/// +/// ```compile_fail,E0277 +/// use thing_contract::Segment; +/// let _: Segment = "a.b".into(); +/// ``` +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Segment(String); + +impl Segment { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() { + Err(DomainError::new("Segment", "non-empty")) + } else if value.contains('.') { + Err(DomainError::new("Segment", "free of '.'")) + } else { + Ok(Self(value)) + } + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +macro_rules! leaf_serde { + ($name:ident, $inner:ty, $serialize:ident) => { + impl Serialize for $name { + fn serialize(&self, serializer: S) -> Result { + serializer.$serialize(self.0) + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize>(deserializer: D) -> Result { + Self::new(<$inner>::deserialize(deserializer)?).map_err(de::Error::custom) + } + } + }; +} + +leaf_serde!(Seq, i32, serialize_i32); +leaf_serde!(Version, i64, serialize_i64); +leaf_serde!(Count, i32, serialize_i32); + +impl Serialize for Segment { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for Segment { + fn deserialize>(deserializer: D) -> Result { + Self::new(String::deserialize(deserializer)?).map_err(de::Error::custom) + } +} diff --git a/crates/thing-contract/src/keys.rs b/crates/thing-contract/src/keys.rs index a59dd8e..f6de211 100644 --- a/crates/thing-contract/src/keys.rs +++ b/crates/thing-contract/src/keys.rs @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize}; +use crate::domain::{Segment, Seq, Version}; + macro_rules! id_type { ($(#[$doc:meta])* $name:ident) => { $(#[$doc])* @@ -42,22 +44,27 @@ id_type!( CommandId ); -pub(crate) fn surface_key(provider: &ProviderId, label: &str) -> String { - format!("thing:surface:{}.{}", provider.as_str(), label) +// Each key joins free-string provider or publisher ids (dotted, as the Verse names them) +// with typed trailing parts: a `Segment` holds no dot and a `Seq` or `Version` is digits. A +// key therefore splits at its last dot (surface, asset catalog, result) or last two +// (composition), so two different field sets never build one key. + +pub(crate) fn surface_key(provider: &ProviderId, label: &Segment) -> String { + format!("thing:surface:{}.{}", provider.as_str(), label.as_str()) } -pub(crate) fn composition_key(publisher: &str, name: &str, version: i32) -> String { - format!("thing:composition:{publisher}.{name}.v{version}") +pub(crate) fn composition_key(publisher: &str, name: &Segment, version: Version) -> String { + format!("thing:composition:{publisher}.{}.v{}", name.as_str(), version.get()) } pub(crate) fn invocation_key(invocation_id: &str) -> String { format!("thing:invocation:{invocation_id}") } -pub(crate) fn result_key(invocation_id: &str, seq: i32) -> String { - format!("thing:result:{invocation_id}.{seq}") +pub(crate) fn result_key(invocation_id: &str, seq: Seq) -> String { + format!("thing:result:{invocation_id}.{}", seq.get()) } -pub(crate) fn asset_catalog_key(provider: &ProviderId, catalog_id: &str) -> String { - format!("thing:assets:{}.{}", provider.as_str(), catalog_id) +pub(crate) fn asset_catalog_key(provider: &ProviderId, catalog_id: &Segment) -> String { + format!("thing:asset_catalog:{}.{}", provider.as_str(), catalog_id.as_str()) } diff --git a/crates/thing-contract/src/lib.rs b/crates/thing-contract/src/lib.rs index 9249d76..9f9d544 100644 --- a/crates/thing-contract/src/lib.rs +++ b/crates/thing-contract/src/lib.rs @@ -3,9 +3,20 @@ //! //! Every kind is a CultCache `DatabaseEntry`; every closed set is a `CultUnion` written //! `[armKey, [slots...]]`, the shape the C# reference reads and writes. +//! +//! Decoding is a door as well as the constructors: every kind keeps its public serde +//! `Deserialize` (CultCache's `DatabaseEntry` requires it). The domains therefore live in the +//! field types (`Seq`, `Version`, `Count`, `Segment`), whose `Deserialize` calls the same +//! `new` the constructors call, so no door skips a check. +//! +//! Hazard: decoding these kinds from untrusted bytes on a default-size thread can abort the +//! process inside CultCache's nesting bound before the bound refuses (the stack is not +//! bounded by nesting depth). The owner is `cultlib-gaps:follow_up:decode-stack-safety`; the +//! tests decode on a 16 MiB thread until it lands. mod asset; mod command; +mod domain; mod keys; mod node; mod result; @@ -14,6 +25,7 @@ mod style; pub use asset::*; pub use command::*; +pub use domain::{Count, DomainError, Segment, Seq, Version}; pub use keys::{CommandId, NodeId, ProviderId, RecordKey, SchemaId}; pub use node::*; pub use result::*; diff --git a/crates/thing-contract/src/node.rs b/crates/thing-contract/src/node.rs index 722076f..603950b 100644 --- a/crates/thing-contract/src/node.rs +++ b/crates/thing-contract/src/node.rs @@ -3,6 +3,7 @@ use cultcache_rs::{CultSlots, CultUnion, DatabaseEntry}; +use crate::domain::{Count, Segment, Version}; use crate::command::{CommandDecl, CommandRef}; use crate::keys::{NodeId, ProviderId, composition_key, surface_key}; use crate::source::{Bind, BoolSource, CollectionSource, MemberPath, NumberSource, TextSource}; @@ -74,7 +75,7 @@ pub struct TextNode { #[cultcache(key = 3)] pub wrap: bool, #[cultcache(key = 4)] - pub max_lines: Option, + pub max_lines: Option, #[cultcache(key = 5)] pub selectable: bool, } @@ -303,9 +304,9 @@ pub struct CompositionRef { #[cultcache(key = 0)] pub publisher: String, #[cultcache(key = 1)] - pub name: String, + pub name: Segment, #[cultcache(key = 2)] - pub version: i32, + pub version: Version, } #[derive(Clone, Debug, PartialEq, CultSlots)] @@ -414,9 +415,9 @@ pub struct SurfaceDocument { #[cultcache(key = 0)] pub provider_id: ProviderId, #[cultcache(key = 1)] - pub label: String, + pub label: Segment, #[cultcache(key = 2)] - pub version: i64, + pub version: Version, #[cultcache(key = 3)] pub title: String, #[cultcache(key = 4)] @@ -438,9 +439,9 @@ pub struct CompositionDocument { #[cultcache(key = 0)] pub publisher: String, #[cultcache(key = 1)] - pub name: String, + pub name: Segment, #[cultcache(key = 2)] - pub version: i32, + pub version: Version, #[cultcache(key = 3)] pub params: Vec, #[cultcache(key = 4)] diff --git a/crates/thing-contract/src/result.rs b/crates/thing-contract/src/result.rs index 647ba11..d2a1bb9 100644 --- a/crates/thing-contract/src/result.rs +++ b/crates/thing-contract/src/result.rs @@ -1,10 +1,12 @@ -//! The answer to a command invocation. A `CommandResult` is built only by its four -//! constructors, each of which copies the invocation's id, and is read through accessors. -//! Nothing here takes a state as a string, and nothing builds a result from a map. +//! The answer to a command invocation. A `CommandResult` is built by its four constructors, +//! each of which copies the invocation's id, and is read through accessors. Decoding is the +//! other door and holds the same domain: `seq` is a `Seq`, refused below 1 whichever door +//! it enters by. No API takes a state as a string or converts a map. use cultcache_rs::{CultSlots, CultUnion, DatabaseEntry}; use crate::command::{CommandInvocation, CommandRef}; +use crate::domain::{Seq, Version}; use crate::keys::{NodeId, result_key}; #[derive(Clone, Debug, PartialEq, CultSlots)] @@ -16,7 +18,7 @@ pub struct PendingOutcome { #[derive(Clone, Debug, PartialEq, CultSlots)] pub struct CompletedOutcome { #[cultcache(key = 0)] - pub source_version: Option, + pub source_version: Option, /// Whether the captured drafts of the invocation clear. #[cultcache(key = 1)] pub clear_drafts: bool, @@ -73,34 +75,77 @@ pub enum Effect { OpenExternal(OpenExternalEffect), } -/// A provider's answer to one invocation. `seq` counts from 1 per invocation. +/// A provider's answer to one invocation. `seq` counts from 1 per invocation. The copied +/// invocation id is a convenience: core binds a result to an invocation it issued. /// /// The positive case, a result built by a constructor from the invocation it answers: /// /// ``` -/// use thing_contract::{CommandInvocation, CommandResult}; +/// use thing_contract::{CommandInvocation, CommandResult, Seq}; /// fn answer(invocation: &CommandInvocation) -> CommandResult { -/// CommandResult::failed(invocation, 1, "refused") +/// CommandResult::failed(invocation, Seq::FIRST, "refused") /// } /// ``` /// /// A struct literal does not compile, because the fields are private: /// /// ```compile_fail,E0451 -/// use thing_contract::{CommandResult, Outcome, FailedOutcome}; +/// use thing_contract::{CommandResult, FailedOutcome, Outcome, Seq}; /// let _ = CommandResult { /// invocation_id: String::new(), -/// seq: 1, +/// seq: Seq::FIRST, /// outcome: Outcome::Failed(FailedOutcome { reason: String::new() }), /// }; /// ``` /// -/// Neither does assigning the outcome: +/// Each field is read through its accessor: +/// +/// ``` +/// use thing_contract::{CommandInvocation, CommandResult, Seq}; +/// fn read(invocation: &CommandInvocation) { +/// let result = CommandResult::failed(invocation, Seq::FIRST, "refused"); +/// let _ = result.invocation_id(); +/// } +/// ``` +/// +/// and assigning it does not compile: /// /// ```compile_fail,E0616 -/// use thing_contract::{CommandInvocation, CommandResult, Outcome, FailedOutcome}; +/// use thing_contract::{CommandInvocation, CommandResult, Seq}; /// fn tamper(invocation: &CommandInvocation) { -/// let mut result = CommandResult::failed(invocation, 1, "refused"); +/// let mut result = CommandResult::failed(invocation, Seq::FIRST, "refused"); +/// result.invocation_id = String::new(); +/// } +/// ``` +/// +/// ``` +/// use thing_contract::{CommandInvocation, CommandResult, Seq}; +/// fn read(invocation: &CommandInvocation) { +/// let result = CommandResult::failed(invocation, Seq::FIRST, "refused"); +/// let _ = result.seq(); +/// } +/// ``` +/// +/// ```compile_fail,E0616 +/// use thing_contract::{CommandInvocation, CommandResult, Seq}; +/// fn tamper(invocation: &CommandInvocation) { +/// let mut result = CommandResult::failed(invocation, Seq::FIRST, "refused"); +/// result.seq = Seq::FIRST.next(); +/// } +/// ``` +/// +/// ``` +/// use thing_contract::{CommandInvocation, CommandResult, Seq}; +/// fn read(invocation: &CommandInvocation) { +/// let result = CommandResult::failed(invocation, Seq::FIRST, "refused"); +/// let _ = result.outcome(); +/// } +/// ``` +/// +/// ```compile_fail,E0616 +/// use thing_contract::{CommandInvocation, CommandResult, FailedOutcome, Outcome, Seq}; +/// fn tamper(invocation: &CommandInvocation) { +/// let mut result = CommandResult::failed(invocation, Seq::FIRST, "refused"); /// result.outcome = Outcome::Failed(FailedOutcome { reason: String::new() }); /// } /// ``` @@ -126,24 +171,24 @@ pub struct CommandResult { #[cultcache(key = 0)] invocation_id: String, #[cultcache(key = 1)] - seq: i32, + seq: Seq, #[cultcache(key = 2)] outcome: Outcome, } impl CommandResult { - fn answering(invocation: &CommandInvocation, seq: i32, outcome: Outcome) -> Self { + fn answering(invocation: &CommandInvocation, seq: Seq, outcome: Outcome) -> Self { Self { invocation_id: invocation.invocation_id.clone(), seq, outcome } } - pub fn pending(invocation: &CommandInvocation, seq: i32, message: impl Into) -> Self { + pub fn pending(invocation: &CommandInvocation, seq: Seq, message: impl Into) -> Self { Self::answering(invocation, seq, Outcome::Pending(PendingOutcome { message: message.into() })) } pub fn completed( invocation: &CommandInvocation, - seq: i32, - source_version: Option, + seq: Seq, + source_version: Option, clear_drafts: bool, effect: Option, ) -> Self { @@ -156,14 +201,14 @@ impl CommandResult { pub fn denied( invocation: &CommandInvocation, - seq: i32, + seq: Seq, reason: impl Into, node: Option, ) -> Self { Self::answering(invocation, seq, Outcome::Denied(DeniedOutcome { reason: reason.into(), node })) } - pub fn failed(invocation: &CommandInvocation, seq: i32, reason: impl Into) -> Self { + pub fn failed(invocation: &CommandInvocation, seq: Seq, reason: impl Into) -> Self { Self::answering(invocation, seq, Outcome::Failed(FailedOutcome { reason: reason.into() })) } @@ -171,7 +216,7 @@ impl CommandResult { &self.invocation_id } - pub fn seq(&self) -> i32 { + pub fn seq(&self) -> Seq { self.seq } diff --git a/crates/thing-contract/src/source.rs b/crates/thing-contract/src/source.rs index 59d48e3..c304068 100644 --- a/crates/thing-contract/src/source.rs +++ b/crates/thing-contract/src/source.rs @@ -3,6 +3,7 @@ use cultcache_rs::{CultSlots, CultUnion}; +use crate::domain::Count; use crate::keys::{ProviderId, RecordKey, SchemaId}; /// A composition parameter, named. @@ -114,7 +115,7 @@ pub struct MemberName { #[derive(Clone, Debug, PartialEq, CultSlots)] pub struct MemberIndex { #[cultcache(key = 0)] - pub index: i32, + pub index: Count, } #[derive(Clone, Debug, PartialEq, CultUnion)] @@ -132,7 +133,7 @@ pub enum Format { #[derive(Clone, Debug, PartialEq, CultSlots)] pub struct NumberFormat { #[cultcache(key = 0)] - pub precision: i32, + pub precision: Count, #[cultcache(key = 1)] pub percent: bool, #[cultcache(key = 2)] diff --git a/crates/thing-contract/tests/common/mod.rs b/crates/thing-contract/tests/common/mod.rs index f111ff1..ee6f71b 100644 --- a/crates/thing-contract/tests/common/mod.rs +++ b/crates/thing-contract/tests/common/mod.rs @@ -5,6 +5,22 @@ use thing_contract::*; +pub fn seq(value: i32) -> Seq { + Seq::new(value).unwrap() +} + +pub fn ver(value: i64) -> Version { + Version::new(value).unwrap() +} + +pub fn count(value: i32) -> Count { + Count::new(value).unwrap() +} + +pub fn seg(value: &str) -> Segment { + Segment::new(value).unwrap() +} + pub fn nid(id: &str) -> NodeId { NodeId::new(id) } @@ -114,7 +130,7 @@ pub fn invocation() -> CommandInvocation { CommandInvocation { invocation_id: "3f2b8c1e-5d44-4a6b-9a07-1c2d3e4f5a6b".into(), surface_key: "thing:surface:provider.a.main".into(), - surface_version: 7, + surface_version: ver(7), command: CommandId::new("go"), payload: RecordPayload { schema: SchemaId::new("gamecult.thing.test.go.v1"), bytes: vec![0x91, 0x01, 0xff, 0x00] }, captured: vec![ diff --git a/crates/thing-contract/tests/goldens.rs b/crates/thing-contract/tests/goldens.rs index 41b8a7f..71a5c23 100644 --- a/crates/thing-contract/tests/goldens.rs +++ b/crates/thing-contract/tests/goldens.rs @@ -45,12 +45,12 @@ fn golden(name: &str, value: &T) -> String } fn bind_all_formats() -> Vec { - let number = Format::Number(NumberFormat { precision: 2, percent: true, unit: Some("ms".into()) }); + let number = Format::Number(NumberFormat { precision: count(2), percent: true, unit: Some("ms".into()) }); let affix = Format::Affix(AffixFormat { prefix: "$".into(), suffix: "/s".into() }); let indexed = MemberPath { steps: vec![ MemberStep::Name(MemberName { name: "items".into() }), - MemberStep::Index(MemberIndex { index: 3 }), + MemberStep::Index(MemberIndex { index: count(3) }), ], }; vec![ @@ -111,7 +111,7 @@ fn full_surface() -> SurfaceDocument { content: TextSource::Bound(item_bind(path(&["name"]))), style_token: "body".into(), wrap: false, - max_lines: Some(2), + max_lines: Some(count(2)), selectable: true, })), }); @@ -155,6 +155,14 @@ fn full_surface() -> SurfaceDocument { initial: TextSource::Param(param("initial")), submit: command("rename"), }), + Node::TextInput(TextInputNode { + common: common("title-input"), + multiline: false, + kind: InputKind::Text, + placeholder: lit_text("title"), + initial: TextSource::Param(param("initial")), + submit: command("retitle"), + }), Node::Select(SelectNode { common: common("pick"), options: OptionList::Literal(LiteralOptions { @@ -206,7 +214,7 @@ fn full_surface() -> SurfaceDocument { }), Node::Use(UseNode { common: common("kv"), - composition: CompositionRef { publisher: "thing.std".into(), name: "key-value".into(), version: 1 }, + composition: CompositionRef { publisher: "thing.std".into(), name: seg("key-value"), version: ver(1) }, args: vec![Arg { name: "key".into(), value: ArgValue::Text(TextArg { value: "k".into() }) }], }), ]; @@ -318,8 +326,8 @@ fn full_surface() -> SurfaceDocument { SurfaceDocument { provider_id: ProviderId::new("provider.a"), - label: "main".into(), - version: 7, + label: seg("main"), + version: ver(7), title: "All arms".into(), root: boxed("root", children), commands: vec![ @@ -357,13 +365,13 @@ fn full_composition() -> CompositionDocument { ]; let inner_use = Node::Use(UseNode { common: common("inner"), - composition: CompositionRef { publisher: "thing.std".into(), name: "card".into(), version: 2 }, + composition: CompositionRef { publisher: "thing.std".into(), name: seg("card"), version: ver(2) }, args, }); CompositionDocument { publisher: "provider.a".into(), - name: "panel".into(), - version: 3, + name: seg("panel"), + version: ver(3), params: [ ("title", ParamKind::Text), ("count", ParamKind::Number), @@ -382,13 +390,13 @@ fn full_composition() -> CompositionDocument { fn results(invocation: &CommandInvocation) -> Vec<(&'static str, CommandResult)> { vec![ - ("result-pending", CommandResult::pending(invocation, 1, "working")), - ("result-completed", CommandResult::completed(invocation, 2, Some(8), true, None)), + ("result-pending", CommandResult::pending(invocation, seq(1), "working")), + ("result-completed", CommandResult::completed(invocation, seq(2), Some(ver(8)), true, None)), ( "result-completed-open-surface", CommandResult::completed( invocation, - 3, + seq(3), None, false, Some(Effect::OpenSurface(OpenSurfaceEffect { key: "thing:surface:provider.a.next".into() })), @@ -398,8 +406,8 @@ fn results(invocation: &CommandInvocation) -> Vec<(&'static str, CommandResult)> "result-completed-open-external", CommandResult::completed( invocation, - 4, - Some(9), + seq(4), + Some(ver(9)), true, Some(Effect::OpenExternal(OpenExternalEffect { url: "https://example.test/auth".into(), @@ -407,20 +415,20 @@ fn results(invocation: &CommandInvocation) -> Vec<(&'static str, CommandResult)> })), ), ), - ("result-denied", CommandResult::denied(invocation, 5, "not yours", Some(nid("go")))), - ("result-denied-no-node", CommandResult::denied(invocation, 6, "not allowed", None)), - ("result-failed", CommandResult::failed(invocation, 7, "broke")), + ("result-denied", CommandResult::denied(invocation, seq(5), "not yours", Some(nid("go")))), + ("result-denied-no-node", CommandResult::denied(invocation, seq(6), "not allowed", None)), + ("result-failed", CommandResult::failed(invocation, seq(7), "broke")), ] } fn asset_catalog() -> AssetCatalog { AssetCatalog { provider_id: ProviderId::new("provider.a"), - catalog_id: "icons".into(), - version: 2, + catalog_id: seg("icons"), + version: ver(2), assets: vec![ - AssetEntry { id: "gear".into(), media_type: "image/png".into(), uri: "assets/gear.png".into(), width: 32, height: 32 }, - AssetEntry { id: "bolt".into(), media_type: "image/svg+xml".into(), uri: "assets/bolt.svg".into(), width: 0, height: 0 }, + AssetEntry { id: "gear".into(), media_type: "image/png".into(), uri: "assets/gear.png".into(), width: count(32), height: count(32) }, + AssetEntry { id: "bolt".into(), media_type: "image/svg+xml".into(), uri: "assets/bolt.svg".into(), width: count(0), height: count(0) }, ], } } @@ -434,7 +442,7 @@ fn write_store(path: &std::path::Path) { let surface = full_surface(); let composition = full_composition(); let invocation = invocation(); - let result = CommandResult::pending(&invocation, 1, "working"); + let result = CommandResult::pending(&invocation, seq(1), "working"); let catalog = asset_catalog(); put(&mut cache, &surface.record_key(), &surface); put(&mut cache, &composition.record_key(), &composition); @@ -505,7 +513,7 @@ fn goldens_match() { let composition = full_composition(); assert_eq!(cache.get::(&composition.record_key()).unwrap(), Some(composition)); assert_eq!(cache.get::(&invocation.record_key()).unwrap(), Some(invocation.clone())); - let result = CommandResult::pending(&invocation, 1, "working"); + let result = CommandResult::pending(&invocation, seq(1), "working"); assert_eq!(cache.get::(&result.record_key()).unwrap(), Some(result)); let catalog = asset_catalog(); assert_eq!(cache.get::(&catalog.record_key()).unwrap(), Some(catalog)); @@ -541,6 +549,8 @@ fn refusal_payloads() -> Vec<(&'static str, Vec)> { }, }; let unit: [u8; 0] = []; + let pending = Outcome::Pending(PendingOutcome { message: CANARY.into() }); + let root = text("root", "x"); vec![ ("result-thing1-flat", rmp_serde::to_vec_named(&flat).unwrap()), // A surface whose root is an arm key the Node union does not declare. @@ -555,8 +565,26 @@ fn refusal_payloads() -> Vec<(&'static str, Vec)> { "result-extra-slot", rmp_serde::to_vec(&("inv-1", 1i32, (0i32, ("working",)), CANARY)).unwrap(), ), - // A result without its outcome. - ("result-missing-slot", rmp_serde::to_vec(&("inv-1", 1i32)).unwrap()), + // Each domain refusal is a tuple of real values with one slot out of domain. + // A result at seq 0. + ("result-seq-zero", rmp_serde::to_vec(&("inv-1", 0i32, &pending)).unwrap()), + // A surface at version -1. + ( + "surface-version-negative", + rmp_serde::to_vec(&("provider.a", "main", -1i64, CANARY, &root, unit)).unwrap(), + ), + // A surface whose label holds a dot, so its key would be ambiguous. + ( + "surface-label-dotted", + rmp_serde::to_vec(&("provider.a", "b.c", 1i64, CANARY, &root, unit)).unwrap(), + ), + // An asset whose width is negative. + ( + "asset-extent-negative", + rmp_serde::to_vec(&("provider.a", "icons", 1i64, [("gear", "image/png", CANARY, -5i32, 32i32)])).unwrap(), + ), + // A colour channel past the u8 leaf. + ("rgba-channel-out-of-range", rmp_serde::to_vec(&(256i32, 0i32, 0i32, 255i32)).unwrap()), ] } @@ -565,19 +593,28 @@ fn refusal_cases() { let payloads: std::collections::BTreeMap<_, _> = refusal_payloads().into_iter().collect(); let committed = |name: &str| golden_bytes(&format!("{name}.bin"), &payloads[name]); - let cases: [(&str, &str, &str); 3] = [ + // Stored under their key, each is refused naming the kind and the key, never a value. + let cases: [(&str, &str, &str); 7] = [ ("result-thing1-flat", "gamecult.thing.command_result", "thing:result:inv-1.1"), ("node-unknown-arm", "gamecult.thing.surface", "thing:surface:provider.a.main"), ("outcome-unknown-arm", "gamecult.thing.command_result", "thing:result:inv-1.1"), + ("result-seq-zero", "gamecult.thing.command_result", "thing:result:inv-1.0"), + ("surface-version-negative", "gamecult.thing.surface", "thing:surface:provider.a.main"), + ("surface-label-dotted", "gamecult.thing.surface", "thing:surface:provider.a.b.c"), + ("asset-extent-negative", "gamecult.thing.asset_catalog", "thing:asset_catalog:provider.a.icons"), ]; for (name, kind, key) in cases { let mut cache = CultCache::new(); ThingContract.register_entries(&mut cache).unwrap(); let bytes = committed(name); - let error = if kind == "gamecult.thing.surface" { - cache.put_envelope::(envelope::(key, bytes)).unwrap_err() - } else { - cache.put_envelope::(envelope::(key, bytes)).unwrap_err() + let error = match kind { + "gamecult.thing.surface" => { + cache.put_envelope::(envelope::(key, bytes)).unwrap_err() + } + "gamecult.thing.asset_catalog" => { + cache.put_envelope::(envelope::(key, bytes)).unwrap_err() + } + _ => cache.put_envelope::(envelope::(key, bytes)).unwrap_err(), }; let message = format!("{error:#}"); assert!(message.contains(kind), "{name}: the error names the kind: {message}"); @@ -597,26 +634,40 @@ fn refusal_cases() { assert!(!message.contains(CANARY), "{name}: {message}"); } - // An extra trailing slot is read (CultLib's extra-trailing-slots ruling); a missing slot is not. + // Decoded directly, a domain refusal names the domain and never the payload. + for (name, domain) in [ + ("result-seq-zero", "Seq"), + ("surface-version-negative", "Version"), + ("surface-label-dotted", "Segment"), + ("asset-extent-negative", "Count"), + ] { + let bytes = committed(name); + let message = match name { + "result-seq-zero" => rmp_serde::from_slice::(&bytes).unwrap_err().to_string(), + "asset-extent-negative" => rmp_serde::from_slice::(&bytes).unwrap_err().to_string(), + _ => rmp_serde::from_slice::(&bytes).unwrap_err().to_string(), + }; + assert!(message.contains(domain), "{name}: {message}"); + assert!(!message.contains(CANARY), "{name}: {message}"); + } + + // The u8 leaf refuses a channel past 255 when decoded as an Rgba. + let rgba = rmp_serde::from_slice::(&committed("rgba-channel-out-of-range")).unwrap_err(); + assert!(!rgba.to_string().contains(CANARY)); + + // An extra trailing slot is read (CultLib's extra-trailing-slots ruling: read, never written + // back). Whether a missing slot is refused is CultLib's rule, so no golden asserts it. let mut answered = invocation(); answered.invocation_id = "inv-1".into(); let extra: CommandResult = rmp_serde::from_slice(&committed("result-extra-slot")).unwrap(); - assert_eq!(extra, CommandResult::pending(&answered, 1, "working")); - let missing = rmp_serde::from_slice::(&committed("result-missing-slot")).unwrap_err(); - println!("missing slot: {missing}"); - let mut cache = CultCache::new(); - ThingContract.register_entries(&mut cache).unwrap(); - let missing = cache - .put_envelope::(envelope::("thing:result:inv-1.1", committed("result-missing-slot"))) - .unwrap_err(); - let message = format!("{missing:#}"); - assert!(message.contains("gamecult.thing.command_result") && message.contains("thing:result:inv-1.1"), "{message}"); + assert_eq!(extra, CommandResult::pending(&answered, seq(1), "working")); } #[test] fn deep_tree() { // Decoding a Node costs tens of KiB of debug stack per nesting level, so the chain runs on a - // thread that has the room; the 2 MiB default does not. + // thread that has the room; the 2 MiB default does not. The hazard is CultCache's, owned by + // cultlib-gaps:follow_up:decode-stack-safety; the test stays on the big thread until it lands. std::thread::Builder::new().stack_size(16 * 1024 * 1024).spawn(deep_tree_body).unwrap().join().unwrap(); } @@ -628,8 +679,8 @@ fn deep_tree_body() { } let deep = SurfaceDocument { provider_id: ProviderId::new("provider.a"), - label: "deep".into(), - version: 1, + label: seg("deep"), + version: ver(1), title: "deep".into(), root: chain, commands: vec![], @@ -639,8 +690,8 @@ fn deep_tree_body() { wide_children.push(boxed("tail", vec![])); let wide = SurfaceDocument { provider_id: ProviderId::new("provider.a"), - label: "wide".into(), - version: 1, + label: seg("wide"), + version: ver(1), title: "wide".into(), root: boxed("root", wide_children), commands: vec![], diff --git a/crates/thing-contract/tests/goldens/README.md b/crates/thing-contract/tests/goldens/README.md index aa985ba..0e7f7d9 100644 --- a/crates/thing-contract/tests/goldens/README.md +++ b/crates/thing-contract/tests/goldens/README.md @@ -12,7 +12,7 @@ when absent. Arm keys are fixed for v1. | File | Kind | Decodes to | |---|---|---| -| `surface-all-arms.bin` | `gamecult.thing.surface.v1` | One surface with all 13 node arms (Box 0, Text 1, Image 2, Button 3, Toggle 4, Slider 5, TextInput 6, Select 7, Repeat 8, Overlay 9, Plot 10, Embed 11, Use 12), a Box chain four levels deep, every text, number and bool source arm, every Format arm, both state sources, both member steps, every dimension and colour arm, and two declared commands | +| `surface-all-arms.bin` | `gamecult.thing.surface.v1` | One surface with all 13 node arms (Box 0, Text 1, Image 2, Button 3, Toggle 4, Slider 5, TextInput 6 as both input kinds, Text 0 and Number 1, Select 7, Repeat 8, Overlay 9, Plot 10, Embed 11, Use 12), a Box chain four levels deep, every text, number and bool source arm, every Format arm, both state sources, both member steps, every dimension and colour arm, and two declared commands | | `composition-all-params.bin` | `gamecult.thing.composition.v1` | A composition with one parameter of each of the 7 kinds, whose body uses a composition with one argument of each of the 7 argument arms | | `invocation-all-inputs.bin` | `gamecult.thing.command_invocation.v1` | An invocation with a payload record of bin bytes and one captured input of each of the 4 input arms | | `result-pending.bin` | `gamecult.thing.command_result.v1` | Outcome Pending, seq 1 | @@ -33,4 +33,10 @@ when absent. Arm keys are fixed for v1. | `node-unknown-arm.bin` | a surface whose root node has arm key 13, which Node does not declare | refused; the error names the kind and the record key, and decoded directly it names the union Node and the key 13, and never a value | | `outcome-unknown-arm.bin` | a result whose outcome has arm key 4, which Outcome does not declare | refused in the same way, naming the union Outcome and the key 4 | | `result-extra-slot.bin` | a Pending result with a fourth slot past the last one declared | read: it decodes to the Pending result with seq 1 (CultLib's extra-trailing-slots ruling: read, never written back) | -| `result-missing-slot.bin` | a result with two slots, no outcome | refused when stored as a command result; the error names the kind and the record key | +| `result-seq-zero.bin` | a result whose seq slot is 0 | refused; the error names the kind and the record key, decoded directly it names Seq, and never a value | +| `surface-version-negative.bin` | a surface whose version slot is -1 | refused in the same way, naming Version | +| `surface-label-dotted.bin` | a surface whose label is `b.c` | refused in the same way, naming Segment: a label holds no dot, so a record key splits at its last dot | +| `asset-extent-negative.bin` | an asset catalog whose first asset has width -5 | refused in the same way, naming Count | +| `rgba-channel-out-of-range.bin` | the Rgba slot array `[256, 0, 0, 255]` | refused when decoded as an Rgba: a channel is a byte, 0 to 255 | + +Domain refusals are the contract's rule, which the TypeScript and C# packages mirror: each is held to refuse these files, without the leaf set a Rust `u8` or a checked newtype has. diff --git a/crates/thing-contract/tests/goldens/result-missing-slot.bin b/crates/thing-contract/tests/goldens/result-missing-slot.bin deleted file mode 100644 index 60a1739..0000000 --- a/crates/thing-contract/tests/goldens/result-missing-slot.bin +++ /dev/null @@ -1 +0,0 @@ -’¥inv-1 \ No newline at end of file diff --git a/crates/thing-contract/tests/result.rs b/crates/thing-contract/tests/result.rs index 2d439a8..4c3b9b6 100644 --- a/crates/thing-contract/tests/result.rs +++ b/crates/thing-contract/tests/result.rs @@ -1,8 +1,14 @@ mod common; use common::*; +use cultcache_rs::{CultCache, CultCacheEnvelope, CultCacheRegistry, DatabaseEntry, decode_payload}; +use serde::Deserialize; +use serde::de::IntoDeserializer; +use serde::de::value::{Error as ValueError, I32Deserializer, I64Deserializer, StringDeserializer}; use thing_contract::*; +const CANARY: &str = "CANARY-9d41c7e0"; + fn other_invocation() -> CommandInvocation { let mut other = invocation(); other.invocation_id = "an-invocation-no-constructor-can-guess".into(); @@ -13,14 +19,14 @@ fn other_invocation() -> CommandInvocation { fn result_copies_invocation_id() { let invocation = other_invocation(); let built = [ - CommandResult::pending(&invocation, 1, "m"), - CommandResult::completed(&invocation, 2, None, false, None), - CommandResult::denied(&invocation, 3, "r", None), - CommandResult::failed(&invocation, 4, "r"), + CommandResult::pending(&invocation, seq(1), "m"), + CommandResult::completed(&invocation, seq(2), None, false, None), + CommandResult::denied(&invocation, seq(3), "r", None), + CommandResult::failed(&invocation, seq(4), "r"), ]; for (index, result) in built.iter().enumerate() { assert_eq!(result.invocation_id(), "an-invocation-no-constructor-can-guess"); - assert_eq!(result.seq(), index as i32 + 1); + assert_eq!(result.seq().get(), index as i32 + 1); } } @@ -29,53 +35,194 @@ fn each_constructor_builds_its_outcome_arm() { let invocation = other_invocation(); let node = Some(nid("go")); assert!(matches!( - CommandResult::pending(&invocation, 1, "wait").outcome(), + CommandResult::pending(&invocation, seq(1), "wait").outcome(), Outcome::Pending(PendingOutcome { message }) if message == "wait" )); let effect = Effect::OpenSurface(OpenSurfaceEffect { key: "k".into() }); assert!(matches!( - CommandResult::completed(&invocation, 1, Some(5), true, Some(effect.clone())).outcome(), - Outcome::Completed(CompletedOutcome { source_version: Some(5), clear_drafts: true, effect: Some(e) }) if *e == effect + CommandResult::completed(&invocation, seq(1), Some(ver(5)), true, Some(effect.clone())).outcome(), + Outcome::Completed(CompletedOutcome { source_version: Some(v), clear_drafts: true, effect: Some(e) }) + if v.get() == 5 && *e == effect )); assert!(matches!( - CommandResult::completed(&invocation, 1, None, false, None).outcome(), + CommandResult::completed(&invocation, seq(1), None, false, None).outcome(), Outcome::Completed(CompletedOutcome { source_version: None, clear_drafts: false, effect: None }) )); assert!(matches!( - CommandResult::denied(&invocation, 1, "no", node.clone()).outcome(), + CommandResult::denied(&invocation, seq(1), "no", node.clone()).outcome(), Outcome::Denied(DeniedOutcome { reason, node: n }) if reason == "no" && *n == node )); assert!(matches!( - CommandResult::failed(&invocation, 1, "broke").outcome(), + CommandResult::failed(&invocation, seq(1), "broke").outcome(), Outcome::Failed(FailedOutcome { reason }) if reason == "broke" )); } +fn surface(provider: &str, label: &str) -> SurfaceDocument { + SurfaceDocument { + provider_id: ProviderId::new(provider), + label: seg(label), + version: ver(1), + title: String::new(), + root: boxed("r", vec![]), + commands: vec![], + } +} + +fn composition(publisher: &str, name: &str, version: i64) -> CompositionDocument { + CompositionDocument { + publisher: publisher.into(), + name: seg(name), + version: ver(version), + params: vec![], + body: boxed("r", vec![]), + } +} + +fn catalog(provider: &str, id: &str) -> AssetCatalog { + AssetCatalog { provider_id: ProviderId::new(provider), catalog_id: seg(id), version: ver(1), assets: vec![] } +} + #[test] fn record_keys_are_built_from_the_fields() { let invocation = other_invocation(); assert_eq!(invocation.record_key(), "thing:invocation:an-invocation-no-constructor-can-guess"); assert_eq!( - CommandResult::failed(&invocation, 3, "r").record_key(), + CommandResult::failed(&invocation, seq(3), "r").record_key(), "thing:result:an-invocation-no-constructor-can-guess.3" ); - let surface = SurfaceDocument { - provider_id: ProviderId::new("p"), - label: "main".into(), - version: 1, - title: String::new(), - root: boxed("r", vec![]), - commands: vec![], - }; - assert_eq!(surface.record_key(), "thing:surface:p.main"); - let composition = CompositionDocument { - publisher: "pub".into(), - name: "card".into(), - version: 2, - params: vec![], - body: boxed("r", vec![]), - }; - assert_eq!(composition.record_key(), "thing:composition:pub.card.v2"); - let catalog = AssetCatalog { provider_id: ProviderId::new("p"), catalog_id: "icons".into(), version: 1, assets: vec![] }; - assert_eq!(catalog.record_key(), "thing:assets:p.icons"); + assert_eq!(surface("p", "main").record_key(), "thing:surface:p.main"); + assert_eq!(composition("pub", "card", 2).record_key(), "thing:composition:pub.card.v2"); + assert_eq!(catalog("p", "icons").record_key(), "thing:asset_catalog:p.icons"); +} + +#[test] +fn record_keys_injective() { + // The pair the old keys collided on: a dotted provider with a plain label, and a plain + // provider with a dotted label. The second no longer exists. + assert_eq!(surface("a.b", "c").record_key(), "thing:surface:a.b.c"); + assert!(Segment::new("b.c").is_err()); + assert_ne!(surface("a", "c").record_key(), surface("a.b", "c").record_key()); + assert_ne!(surface("a.b", "c").record_key(), surface("a", "bc").record_key()); + + // A composition splits at its last two dots: publisher, name, then the version. + assert_eq!(composition("a.b", "c", 1).record_key(), "thing:composition:a.b.c.v1"); + assert_ne!(composition("a.b", "c", 1).record_key(), composition("a", "bc", 1).record_key()); + assert_ne!(composition("a", "c", 1).record_key(), composition("a", "c", 11).record_key()); + assert_ne!(composition("a", "c", 1).record_key(), composition("a", "c", 0).record_key()); + + // A catalog likewise, and under the asset_catalog prefix. + assert_eq!(catalog("a.b", "c").record_key(), "thing:asset_catalog:a.b.c"); + assert_ne!(catalog("a.b", "c").record_key(), catalog("a", "bc").record_key()); + + // A result splits at its last dot, and the seq is digits. + let mut inv = invocation(); + inv.invocation_id = "x.1".into(); + let first = CommandResult::failed(&inv, seq(2), "r").record_key(); + inv.invocation_id = "x".into(); + let second = CommandResult::failed(&inv, seq(12), "r").record_key(); + assert_eq!(first, "thing:result:x.1.2"); + assert_eq!(second, "thing:result:x.12"); + assert_ne!(first, second); + + // Nothing in src spells the old prefix. + assert!(!catalog("p", "c").record_key().starts_with("thing:assets:")); +} + +#[test] +fn domain_newtypes_refuse_out_of_domain() { + // new, the one door. + for bad in [0, -1, i32::MIN] { + assert!(Seq::new(bad).is_err(), "Seq {bad}"); + } + assert_eq!(Seq::new(1).unwrap(), Seq::FIRST); + assert_eq!(Seq::new(i32::MAX).unwrap().next(), Seq::new(i32::MAX).unwrap()); + assert_eq!(Seq::FIRST.next().get(), 2); + assert!(Version::new(-1).is_err()); + assert!(Version::new(i64::MIN).is_err()); + assert_eq!(Version::new(0).unwrap().get(), 0); + assert!(Count::new(-1).is_err()); + assert!(Count::new(i32::MIN).is_err()); + assert_eq!(Count::new(0).unwrap().get(), 0); + assert!(Segment::new("").is_err()); + assert!(Segment::new("a.b").is_err()); + assert!(Segment::new(".").is_err()); + assert_eq!(Segment::new("ab").unwrap().as_str(), "ab"); + + // serde, the other door: rmp_serde and serde's own IntoDeserializer. + for bad in [0, -1, i32::MIN] { + assert!(rmp_serde::from_slice::(&rmp_serde::to_vec(&bad).unwrap()).is_err(), "Seq {bad}"); + assert!(Seq::deserialize(I32Deserializer::::new(bad)).is_err()); + assert!(Count::deserialize(I32Deserializer::::new(bad)).is_ok() == (bad >= 0)); + } + assert_eq!(Seq::deserialize(I32Deserializer::::new(1)).unwrap(), Seq::FIRST); + assert!(rmp_serde::from_slice::(&rmp_serde::to_vec(&-1i64).unwrap()).is_err()); + assert!(Version::deserialize(I64Deserializer::::new(-1)).is_err()); + assert_eq!(Version::deserialize(I64Deserializer::::new(0)).unwrap().get(), 0); + assert!(rmp_serde::from_slice::(&rmp_serde::to_vec(&-1i32).unwrap()).is_err()); + assert_eq!(Count::deserialize(I32Deserializer::::new(0)).unwrap().get(), 0); + for bad in ["", "a.b"] { + assert!(rmp_serde::from_slice::(&rmp_serde::to_vec(bad).unwrap()).is_err(), "Segment {bad:?}"); + let deserializer: StringDeserializer = bad.to_string().into_deserializer(); + assert!(Segment::deserialize(deserializer).is_err()); + } + let deserializer: StringDeserializer = "ab".to_string().into_deserializer(); + assert_eq!(Segment::deserialize(deserializer).unwrap().as_str(), "ab"); + + // Each writes exactly the plain leaf, so the bytes are the leaf's. + assert_eq!(rmp_serde::to_vec(&seq(5)).unwrap(), rmp_serde::to_vec(&5i32).unwrap()); + assert_eq!(rmp_serde::to_vec(&ver(5)).unwrap(), rmp_serde::to_vec(&5i64).unwrap()); + assert_eq!(rmp_serde::to_vec(&count(5)).unwrap(), rmp_serde::to_vec(&5i32).unwrap()); + assert_eq!(rmp_serde::to_vec(&seg("ab")).unwrap(), rmp_serde::to_vec("ab").unwrap()); + + // The error names the domain and its bound, never the value. + let message = Segment::new(format!("{CANARY}.x")).unwrap_err().to_string(); + assert_eq!(message, "Segment must be free of '.'"); + assert_eq!(Seq::new(-77).unwrap_err().to_string(), "Seq must be at least 1"); + assert_eq!(Version::new(-77).unwrap_err().to_string(), "Version must be at least 0"); + assert_eq!(Count::new(-77).unwrap_err().to_string(), "Count must be at least 0"); + assert_eq!(Segment::new("").unwrap_err().to_string(), "Segment must be non-empty"); + let error = Segment::new(format!("{CANARY}.x")).unwrap_err(); + assert_eq!((error.domain(), error.bound()), ("Segment", "free of '.'")); + assert!(!format!("{error:?}").contains(CANARY)); +} + +/// The result payload `[id, seq, [0, [message]]]` with the seq slot spelled by the caller. +fn result_payload(seq_slot: i32) -> Vec { + let pending = Outcome::Pending(PendingOutcome { message: CANARY.into() }); + rmp_serde::to_vec(&("inv-1", seq_slot, &pending)).unwrap() +} + +#[test] +fn result_decode_door_holds_domain() { + // A seq the constructors cannot build is refused by every decode door. + for bad in [0, -1, i32::MIN] { + let payload = result_payload(bad); + let message = rmp_serde::from_slice::(&payload).unwrap_err().to_string(); + assert!(message.contains("Seq") && !message.contains(CANARY), "{message}"); + + let message = format!("{:#}", decode_payload::(&payload).unwrap_err()); + assert!(!message.contains(CANARY), "{message}"); + + let mut cache = CultCache::new(); + ThingContract.register_entries(&mut cache).unwrap(); + let key = format!("thing:result:inv-1.{bad}"); + let envelope = CultCacheEnvelope { + key: key.clone(), + r#type: CommandResult::TYPE.into(), + payload, + stored_at: "2026-01-01T00:00:00Z".into(), + schema_id: Some(CommandResult::TYPE.into()), + }; + let message = format!("{:#}", cache.put_envelope::(envelope).unwrap_err()); + assert!(message.contains("gamecult.thing.command_result") && message.contains(&key), "{message}"); + assert!(!message.contains(CANARY), "{message}"); + } + + // The boundary decodes, and equals what the constructor builds. + let mut answered = invocation(); + answered.invocation_id = "inv-1".into(); + let expected = CommandResult::pending(&answered, Seq::FIRST, CANARY); + assert_eq!(rmp_serde::from_slice::(&result_payload(1)).unwrap(), expected); + assert_eq!(decode_payload::(&result_payload(1)).unwrap(), expected); }