Skip to main content

rustev_contract/
document.rs

1//! The document protocol: schema check, bounded parse, canonical bytes and
2//! identity (spec 002, 3.2 and 3.3).
3
4use std::fmt;
5
6use serde::Serialize;
7use serde::de::DeserializeOwned;
8
9use crate::bounded::{ParseError, ParseLimits, parse_bounded};
10use crate::canonical::{CanonicalError, canonical_bytes, record_canonical_bytes, tagged_digest};
11use crate::ids::ContentDigest;
12
13/// A Rustev document with a schema string and a limit set.
14pub trait Document: Serialize + DeserializeOwned {
15    /// The schema string this document carries (spec 002, 3.2.1).
16    const SCHEMA: &'static str;
17    /// The limit set its bytes are scanned against (spec 002, 3.2.5).
18    const LIMITS: ParseLimits;
19    /// The value of the document's own `schema` field.
20    fn schema(&self) -> &str;
21
22    /// Scan, deserialize and check the schema string.
23    fn parse(bytes: &[u8]) -> Result<Self, DocumentError> {
24        let doc: Self = parse_bounded(bytes, &Self::LIMITS).map_err(DocumentError::Parse)?;
25        if doc.schema() != Self::SCHEMA {
26            return Err(DocumentError::Schema {
27                expected: Self::SCHEMA,
28                found: doc.schema().chars().take(80).collect(),
29            });
30        }
31        Ok(doc)
32    }
33
34    /// The canonical bytes (spec 002, 3.3.1).
35    fn canonical(&self) -> Result<Vec<u8>, CanonicalError> {
36        canonical_bytes(self)
37    }
38
39    /// The record canonical bytes (spec 004, 5.6): the canonical bytes, with
40    /// finite fractional numbers written in shortest round-trip form.
41    fn record_canonical(&self) -> Result<Vec<u8>, CanonicalError>
42    where
43        Self: PartialEq,
44    {
45        record_canonical_bytes(self)
46    }
47
48    /// `sha256(SCHEMA || 0x00 || record canonical bytes)` (spec 004, 3.1.6).
49    /// Equal to the identity of an identified document.
50    fn record_digest(&self) -> Result<ContentDigest, CanonicalError>
51    where
52        Self: PartialEq,
53    {
54        Ok(ContentDigest::from_digest(tagged_digest(
55            Self::SCHEMA,
56            &self.record_canonical()?,
57        )))
58    }
59}
60
61/// A document whose identity is the tagged digest of its canonical bytes.
62pub trait Identified: Document {
63    type Id;
64    #[doc(hidden)]
65    fn wrap(digest: String) -> Self::Id;
66
67    /// `sha256(SCHEMA || 0x00 || canonical bytes)` (spec 002, 3.3.2).
68    fn id(&self) -> Result<Self::Id, CanonicalError> {
69        Ok(Self::wrap(tagged_digest(Self::SCHEMA, &self.canonical()?)))
70    }
71}
72
73/// Why a document was refused (refusal category 1, `parse`).
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum DocumentError {
76    Parse(ParseError),
77    Schema {
78        expected: &'static str,
79        found: String,
80    },
81}
82
83impl fmt::Display for DocumentError {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        match self {
86            DocumentError::Parse(e) => write!(f, "{e}"),
87            DocumentError::Schema { expected, found } => {
88                write!(f, "schema {found:?} where {expected:?} is required")
89            }
90        }
91    }
92}
93
94impl std::error::Error for DocumentError {}
95
96/// Implements [`Document`] (and optionally [`Identified`]) for a type with a
97/// `schema: String` field.
98macro_rules! document {
99    ($ty:ty, $schema:expr, $limits:expr) => {
100        impl $crate::Document for $ty {
101            const SCHEMA: &'static str = $schema;
102            const LIMITS: $crate::bounded::ParseLimits = $limits;
103            fn schema(&self) -> &str {
104                &self.schema
105            }
106        }
107    };
108    ($ty:ty, $schema:expr, $limits:expr, $id:ty) => {
109        $crate::document::document!($ty, $schema, $limits);
110        impl $crate::Identified for $ty {
111            type Id = $id;
112            fn wrap(digest: String) -> $id {
113                <$id>::from_digest(digest)
114            }
115        }
116    };
117}
118
119pub(crate) use document;
120
121/// True when `expected` is exactly `schemas` joined with " or ". Evaluated
122/// at compile time by [`versioned_document!`], so a refusal's `expected`
123/// cannot drift from the schemas `parse` accepts.
124pub(crate) const fn lists_schemas(expected: &str, schemas: &[&str]) -> bool {
125    const SEP: &[u8] = b" or ";
126    let e = expected.as_bytes();
127    let mut at = 0;
128    let mut i = 0;
129    while i < schemas.len() {
130        if i > 0 {
131            let mut k = 0;
132            while k < SEP.len() {
133                if at >= e.len() || e[at] != SEP[k] {
134                    return false;
135                }
136                at += 1;
137                k += 1;
138            }
139        }
140        let s = schemas[i].as_bytes();
141        let mut k = 0;
142        while k < s.len() {
143            if at >= e.len() || e[at] != s[k] {
144                return false;
145            }
146            at += 1;
147            k += 1;
148        }
149        i += 1;
150    }
151    at == e.len()
152}
153
154/// Implements [`Document`] (and optionally [`Identified`]) for a type that
155/// carries one of several schema versions in one wire shape (spec 032,
156/// 3.1.1). `SCHEMA` names the first version, because the trait holds one
157/// constant; `parse` accepts every listed version (a refusal names them all,
158/// as `expected`), and the record digest and
159/// identity are tagged with the document's own schema, so a first-version
160/// identity is what it always was.
161///
162/// `check = f` adds a rule the parser applies after the schema string:
163/// `f(&doc)` returning `Err(message)` refuses the document as
164/// [`ParseError::Schema`](crate::bounded::ParseError::Schema), for members
165/// that belong to some versions only.
166macro_rules! versioned_document {
167    (@impl $ty:ty, [$first:expr $(, $rest:expr)*], $expected:literal, $limits:expr, $check:expr) => {
168        const _: () = assert!(
169            $crate::document::lists_schemas($expected, &[$first $(, $rest)*]),
170            "`expected` must list the accepted schemas, joined with \" or \"",
171        );
172        impl $crate::Document for $ty {
173            const SCHEMA: &'static str = $first;
174            const LIMITS: $crate::bounded::ParseLimits = $limits;
175            fn schema(&self) -> &str {
176                &self.schema
177            }
178
179            fn parse(bytes: &[u8]) -> Result<Self, $crate::DocumentError> {
180                let doc: Self = $crate::bounded::parse_bounded(bytes, &Self::LIMITS)
181                    .map_err($crate::DocumentError::Parse)?;
182                if doc.schema != $first $(&& doc.schema != $rest)* {
183                    return Err($crate::DocumentError::Schema {
184                        expected: $expected,
185                        found: doc.schema.chars().take(80).collect(),
186                    });
187                }
188                ($check)(&doc).map_err(|message: String| {
189                    $crate::DocumentError::Parse($crate::bounded::ParseError::Schema { message })
190                })?;
191                Ok(doc)
192            }
193
194            fn record_digest(
195                &self,
196            ) -> Result<$crate::ids::ContentDigest, $crate::canonical::CanonicalError> {
197                Ok($crate::ids::ContentDigest::from_digest(
198                    $crate::canonical::tagged_digest(&self.schema, &self.record_canonical()?),
199                ))
200            }
201        }
202    };
203    ($ty:ty, [$first:expr $(, $rest:expr)*], $expected:literal, $limits:expr, check = $check:path) => {
204        $crate::document::versioned_document!(
205            @impl $ty, [$first $(, $rest)*], $expected, $limits, $check
206        );
207    };
208    ($ty:ty, [$first:expr $(, $rest:expr)*], $expected:literal, $limits:expr) => {
209        $crate::document::versioned_document!(
210            @impl $ty, [$first $(, $rest)*], $expected, $limits,
211            |_: &$ty| -> Result<(), String> { Ok(()) }
212        );
213    };
214    ($ty:ty, [$first:expr $(, $rest:expr)*], $expected:literal, $limits:expr, $id:ty) => {
215        $crate::document::versioned_document!($ty, [$first $(, $rest)*], $expected, $limits);
216        impl $crate::Identified for $ty {
217            type Id = $id;
218            fn wrap(digest: String) -> $id {
219                <$id>::from_digest(digest)
220            }
221
222            fn id(&self) -> Result<$id, $crate::canonical::CanonicalError> {
223                Ok(Self::wrap($crate::canonical::tagged_digest(
224                    &self.schema,
225                    &$crate::Document::canonical(self)?,
226                )))
227            }
228        }
229    };
230}
231
232pub(crate) use versioned_document;
233
234#[cfg(test)]
235mod tests {
236    use super::lists_schemas;
237
238    #[test]
239    fn expected_must_list_every_schema_in_order() {
240        assert!(lists_schemas("a/1 or a/2", &["a/1", "a/2"]));
241        assert!(lists_schemas("a/1", &["a/1"]));
242        assert!(!lists_schemas("a/1 or a/3", &["a/1", "a/2"]), "renamed");
243        assert!(!lists_schemas("a/1", &["a/1", "a/2"]), "missing");
244        assert!(
245            !lists_schemas("a/1 or a/2 or a/3", &["a/1", "a/2"]),
246            "extra"
247        );
248        assert!(!lists_schemas("a/2 or a/1", &["a/1", "a/2"]), "order");
249    }
250}