diff --git a/rust/catalyst-signed-doc-spec/src/metadata/chain.rs b/rust/catalyst-signed-doc-spec/src/metadata/chain.rs new file mode 100644 index 00000000000..43189989be7 --- /dev/null +++ b/rust/catalyst-signed-doc-spec/src/metadata/chain.rs @@ -0,0 +1,9 @@ +//! `signed_doc.json` "chain" field JSON definition + +use crate::is_required::IsRequired; + +/// `signed_doc.json` "chain" field JSON object +#[derive(serde::Deserialize)] +pub struct Chain { + pub required: IsRequired, +} diff --git a/rust/catalyst-signed-doc-spec/src/metadata/mod.rs b/rust/catalyst-signed-doc-spec/src/metadata/mod.rs index 2d555310570..63789fd41fd 100644 --- a/rust/catalyst-signed-doc-spec/src/metadata/mod.rs +++ b/rust/catalyst-signed-doc-spec/src/metadata/mod.rs @@ -1,5 +1,6 @@ //! `metadata` field definition +pub mod chain; pub mod doc_ref; pub mod parameters; pub mod reply; @@ -14,4 +15,5 @@ pub struct Metadata { pub doc_ref: doc_ref::Ref, pub reply: reply::Reply, pub parameters: parameters::Parameters, + pub chain: chain::Chain, } diff --git a/rust/signed_doc/src/metadata/chain.rs b/rust/signed_doc/src/metadata/chain.rs index 4d53514e7ec..f8b5403536f 100644 --- a/rust/signed_doc/src/metadata/chain.rs +++ b/rust/signed_doc/src/metadata/chain.rs @@ -34,6 +34,32 @@ pub struct Chain { document_ref: Option, } +impl Chain { + /// Creates a new `Chain`. + #[must_use] + pub fn new( + height: i32, + document_ref: Option, + ) -> Self { + Self { + height, + document_ref, + } + } + + /// Gets `height`. + #[must_use] + pub fn height(&self) -> i32 { + self.height + } + + /// Gets `document_ref`. + #[must_use] + pub fn document_ref(&self) -> Option<&DocumentRef> { + self.document_ref.as_ref() + } +} + impl Display for Chain { fn fmt( &self, diff --git a/rust/signed_doc/src/metadata/document_refs/doc_ref.rs b/rust/signed_doc/src/metadata/document_refs/doc_ref.rs index c775f9942b9..71bef70079a 100644 --- a/rust/signed_doc/src/metadata/document_refs/doc_ref.rs +++ b/rust/signed_doc/src/metadata/document_refs/doc_ref.rs @@ -7,6 +7,7 @@ use cbork_utils::{array::Array, decode_context::DecodeCtx}; use minicbor::{Decode, Encode}; use super::doc_locator::DocLocator; +use crate::CatalystSignedDocument; /// Number of item that should be in each document reference instance. const DOC_REF_ARR_ITEM: u64 = 3; @@ -57,6 +58,18 @@ impl DocumentRef { } } +impl TryFrom<&CatalystSignedDocument> for DocumentRef { + type Error = anyhow::Error; + + fn try_from(value: &CatalystSignedDocument) -> Result { + Ok(Self::new( + value.doc_id()?, + value.doc_ver()?, + DocLocator::default(), + )) + } +} + impl Display for DocumentRef { fn fmt( &self, diff --git a/rust/signed_doc/src/validator/rules/chain/mod.rs b/rust/signed_doc/src/validator/rules/chain/mod.rs new file mode 100644 index 00000000000..1764268d2b1 --- /dev/null +++ b/rust/signed_doc/src/validator/rules/chain/mod.rs @@ -0,0 +1,163 @@ +//! `chain` rule type impl. + +use catalyst_signed_doc_spec::{is_required::IsRequired, metadata::chain::Chain, DocSpecs}; + +use crate::{providers::CatalystSignedDocumentProvider, CatalystSignedDocument}; + +#[cfg(test)] +mod tests; + +/// `chain` field validation rule +#[derive(Debug)] +pub(crate) enum ChainRule { + /// Is 'chain' specified + #[allow(dead_code)] + Specified { + /// optional flag for the `chain` field + optional: bool, + }, + /// 'chain' is not specified + NotSpecified, +} + +impl ChainRule { + /// Generating `ChainRule` from specs + pub(crate) fn new( + _docs: &DocSpecs, + spec: &Chain, + ) -> Self { + let optional = match spec.required { + IsRequired::Yes => false, + IsRequired::Optional => true, + IsRequired::Excluded => { + return Self::NotSpecified; + }, + }; + + Self::Specified { optional } + } + + /// Field validation rule + #[allow(clippy::too_many_lines)] + pub(crate) async fn check( + &self, + doc: &CatalystSignedDocument, + provider: &Provider, + ) -> anyhow::Result + where + Provider: CatalystSignedDocumentProvider, + { + let chain = doc.doc_meta().chain(); + + // TODO: the current implementation is only for the direct chained doc, + // make it recursively checks the entire chain with the same `id` docs. + + if let Self::Specified { optional } = self { + if chain.is_none() && !optional { + doc.report() + .missing_field("chain", "Document must have 'chain' field"); + return Ok(false); + } + + // perform integrity validation + if let Some(doc_chain) = chain { + if doc_chain.document_ref().is_none() && doc_chain.height() != 0 { + doc.report().functional_validation( + "The chain height must be zero when there is no chained doc", + "Chained Documents validation", + ); + return Ok(false); + } + if doc_chain.height() == 0 && doc_chain.document_ref().is_some() { + doc.report().functional_validation( + "The next Chained Document must not exist while the height is zero", + "Chained Documents validation", + ); + return Ok(false); + } + + if let Some(chained_ref) = doc_chain.document_ref() { + let Some(chained_doc) = provider.try_get_doc(chained_ref).await? else { + doc.report().other( + &format!( + "Cannot find the Chained Document ({chained_ref}) from the provider" + ), + "Chained Documents validation", + ); + return Ok(false); + }; + + // have the same id as the document being chained to. + if chained_doc.doc_id()? != doc.doc_id()? { + doc.report().functional_validation( + "Must have the same id as the document being chained to", + "Chained Documents validation", + ); + return Ok(false); + } + + // have a ver that is greater than the ver being chained to. + if chained_doc.doc_ver()? > doc.doc_ver()? { + doc.report().functional_validation( + "Must have a ver that is greater than the ver being chained to", + "Chained Documents validation", + ); + return Ok(false); + } + + // have the same type as the chained document. + if chained_doc.doc_type()? != doc.doc_type()? { + doc.report().functional_validation( + "Must have the same type as the chained document", + "Chained Documents validation", + ); + return Ok(false); + } + + if let Some(chained_height) = + chained_doc.doc_meta().chain().map(crate::Chain::height) + { + // chain doc must not be negative + if chained_height < 0 { + doc.report().functional_validation( + "The height of the document being chained to must be positive number", + "Chained Documents validation", + ); + return Ok(false); + } + + // have its absolute height exactly one more than the height of the + // document being chained to. + if !matches!( + i32::abs(doc_chain.height()).checked_sub(i32::abs(chained_height)), + Some(1) + ) { + doc.report().functional_validation( + "Must have its absolute height exactly one more than the height of the document being chained to", + "Chained Documents validation", + ); + return Ok(false); + } + } + } + } + } + if let Self::NotSpecified = self { + if chain.is_some() { + doc.report().unknown_field( + "chain", + &doc.doc_meta() + .chain() + .iter() + .map(ToString::to_string) + .reduce(|a, b| format!("{a}, {b}")) + .unwrap_or_default(), + "Document does not expect to have 'chain' field", + ); + return Ok(false); + } + } + + Ok(true) + } +} diff --git a/rust/signed_doc/src/validator/rules/chain/tests.rs b/rust/signed_doc/src/validator/rules/chain/tests.rs new file mode 100644 index 00000000000..c92f9642ec7 --- /dev/null +++ b/rust/signed_doc/src/validator/rules/chain/tests.rs @@ -0,0 +1,303 @@ +use catalyst_types::uuid::{UuidV4, UuidV7}; +use test_case::test_case; + +use super::*; +use crate::{ + builder::tests::Builder, metadata::SupportedField, providers::tests::TestCatalystProvider, + Chain, DocType, DocumentRef, +}; + +mod helper { + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + use catalyst_types::uuid::UuidV7; + use uuid::{Timestamp, Uuid}; + + pub(super) fn get_now_plus_uuidv7(secs: u64) -> UuidV7 { + let future_time = SystemTime::now() + .checked_add(Duration::from_secs(secs)) + .unwrap(); + let duration_since_epoch = future_time.duration_since(UNIX_EPOCH).unwrap(); + + let unix_secs = duration_since_epoch.as_secs(); + let nanos = duration_since_epoch.subsec_nanos(); + + let ts = Timestamp::from_unix(uuid::NoContext, unix_secs, nanos); + let uuid = Uuid::new_v7(ts); + + UuidV7::try_from_uuid(uuid).unwrap() + } +} + +#[tokio::test] +async fn test_without_chaining_documents() { + let doc_type = UuidV4::new(); + let doc_id = UuidV7::new(); + let doc_ver = UuidV7::new(); + + let provider = TestCatalystProvider::default(); + let doc = Builder::new() + .with_metadata_field(SupportedField::Type(DocType::from(doc_type))) + .with_metadata_field(SupportedField::Id(doc_id)) + .with_metadata_field(SupportedField::Ver(doc_ver)) + .build(); + + let rule = ChainRule::NotSpecified; + assert!(rule.check(&doc, &provider).await.unwrap()); + let rule = ChainRule::Specified { optional: true }; + assert!(rule.check(&doc, &provider).await.unwrap()); + let rule = ChainRule::Specified { optional: false }; + assert!(!rule.check(&doc, &provider).await.unwrap()); +} + +#[test_case( + { + let doc_type = UuidV4::new(); + let doc_id = UuidV7::new(); + + let mut provider = TestCatalystProvider::default(); + + let first_doc_ver = UuidV7::new(); + let first = Builder::new() + .with_metadata_field(SupportedField::Type(DocType::from(doc_type))) + .with_metadata_field(SupportedField::Id(doc_id)) + .with_metadata_field(SupportedField::Ver(first_doc_ver)) + .with_metadata_field(SupportedField::Chain( + Chain::new(0, None) + )) + .build(); + let first_doc_ref = DocumentRef::try_from(&first).unwrap(); + + let last_doc_ver = helper::get_now_plus_uuidv7(60); + let last = Builder::new() + .with_metadata_field(SupportedField::Type(DocType::from(doc_type))) + .with_metadata_field(SupportedField::Id(doc_id)) + .with_metadata_field(SupportedField::Ver(last_doc_ver)) + .with_metadata_field(SupportedField::Chain( + Chain::new(-1, Some(first_doc_ref.clone())) + )) + .build(); + let last_doc_ref = DocumentRef::try_from(&last).unwrap(); + + provider.add_document(Some(first_doc_ref), &first).unwrap(); + provider.add_document(Some(last_doc_ref), &last).unwrap(); + + (provider, last) + } => true; + "valid minimal chained documents (0, -1)" +)] +#[test_case( + { + let doc_type = UuidV4::new(); + let doc_id = UuidV7::new(); + + let mut provider = TestCatalystProvider::default(); + + let first_doc_ver = UuidV7::new(); + let first = Builder::new() + .with_metadata_field(SupportedField::Type(DocType::from(doc_type))) + .with_metadata_field(SupportedField::Id(doc_id)) + .with_metadata_field(SupportedField::Ver(first_doc_ver)) + .with_metadata_field(SupportedField::Chain( + Chain::new(0, None) + )) + .build(); + let first_doc_ref = DocumentRef::try_from(&first).unwrap(); + + let intermediate_doc_ver = helper::get_now_plus_uuidv7(60); + let intermediate = Builder::new() + .with_metadata_field(SupportedField::Type(DocType::from(doc_type))) + .with_metadata_field(SupportedField::Id(doc_id)) + .with_metadata_field(SupportedField::Ver(intermediate_doc_ver)) + .with_metadata_field(SupportedField::Chain( + Chain::new(1, Some(first_doc_ref.clone())) + )) + .build(); + let intermediate_doc_ref = DocumentRef::try_from(&intermediate).unwrap(); + + let last_doc_ver = helper::get_now_plus_uuidv7(120); + let last = Builder::new() + .with_metadata_field(SupportedField::Type(DocType::from(doc_type))) + .with_metadata_field(SupportedField::Id(doc_id)) + .with_metadata_field(SupportedField::Ver(last_doc_ver)) + .with_metadata_field(SupportedField::Chain( + Chain::new(-2, Some(intermediate_doc_ref.clone())) + )) + .build(); + let last_doc_ref = DocumentRef::try_from(&last).unwrap(); + + provider.add_document(Some(first_doc_ref), &first).unwrap(); + provider.add_document(Some(intermediate_doc_ref), &intermediate).unwrap(); + provider.add_document(Some(last_doc_ref), &last).unwrap(); + + (provider, last) + } => true; + "valid intermediate chained documents (0, 1, -2)" +)] +#[tokio::test] +async fn test_valid_chained_documents( + (provider, doc): (TestCatalystProvider, CatalystSignedDocument) +) -> bool { + let rule = ChainRule::Specified { optional: false }; + + rule.check(&doc, &provider).await.unwrap() +} + +#[test_case( + { + let doc_type = UuidV4::new(); + let doc_id = UuidV7::new(); + // with another doc id + let doc_id_another = UuidV7::new(); + + let mut provider = TestCatalystProvider::default(); + + let first_doc_ver = UuidV7::new(); + let first = Builder::new() + .with_metadata_field(SupportedField::Type(DocType::from(doc_type))) + .with_metadata_field(SupportedField::Id(doc_id)) + .with_metadata_field(SupportedField::Ver(first_doc_ver)) + .with_metadata_field(SupportedField::Chain( + Chain::new(0, None) + )) + .build(); + let first_doc_ref = DocumentRef::try_from(&first).unwrap(); + + let last_doc_ver = helper::get_now_plus_uuidv7(60); + let last = Builder::new() + .with_metadata_field(SupportedField::Type(DocType::from(doc_type))) + .with_metadata_field(SupportedField::Id(doc_id_another)) + .with_metadata_field(SupportedField::Ver(last_doc_ver)) + .with_metadata_field(SupportedField::Chain( + Chain::new(-1, Some(first_doc_ref.clone())) + )) + .build(); + let last_doc_ref = DocumentRef::try_from(&last).unwrap(); + + provider.add_document(Some(first_doc_ref), &first).unwrap(); + provider.add_document(Some(last_doc_ref), &last).unwrap(); + + (provider, last) + } => false; + "not have the same id as the document being chained to" +)] +#[test_case( + { + let doc_type = UuidV4::new(); + let doc_id = UuidV7::new(); + + let mut provider = TestCatalystProvider::default(); + + let first_doc_ver = UuidV7::new(); + let first = Builder::new() + .with_metadata_field(SupportedField::Type(DocType::from(doc_type))) + .with_metadata_field(SupportedField::Id(doc_id)) + .with_metadata_field(SupportedField::Ver(first_doc_ver)) + .with_metadata_field(SupportedField::Chain( + Chain::new(0, None) + )) + .build(); + let first_doc_ref = DocumentRef::try_from(&first).unwrap(); + + // same version + let last_doc_ver = first_doc_ver; + let last = Builder::new() + .with_metadata_field(SupportedField::Type(DocType::from(doc_type))) + .with_metadata_field(SupportedField::Id(doc_id)) + .with_metadata_field(SupportedField::Ver(last_doc_ver)) + .with_metadata_field(SupportedField::Chain( + Chain::new(-1, Some(first_doc_ref.clone())) + )) + .build(); + let last_doc_ref = DocumentRef::try_from(&last).unwrap(); + + provider.add_document(Some(first_doc_ref), &first).unwrap(); + provider.add_document(Some(last_doc_ref), &last).unwrap(); + + (provider, last) + } => false; + "not have a ver that is greater than the ver being chained to" +)] +#[test_case( + { + let doc_type = UuidV4::new(); + // with another doc type + let doc_type_another = UuidV4::new(); + let doc_id = UuidV7::new(); + + let mut provider = TestCatalystProvider::default(); + + let first_doc_ver = UuidV7::new(); + let first = Builder::new() + .with_metadata_field(SupportedField::Type(DocType::from(doc_type))) + .with_metadata_field(SupportedField::Id(doc_id)) + .with_metadata_field(SupportedField::Ver(first_doc_ver)) + .with_metadata_field(SupportedField::Chain( + Chain::new(0, None) + )) + .build(); + let first_doc_ref = DocumentRef::try_from(&first).unwrap(); + + let last_doc_ver = helper::get_now_plus_uuidv7(60); + let last = Builder::new() + .with_metadata_field(SupportedField::Type(DocType::from(doc_type_another))) + .with_metadata_field(SupportedField::Id(doc_id)) + .with_metadata_field(SupportedField::Ver(last_doc_ver)) + .with_metadata_field(SupportedField::Chain( + Chain::new(-1, Some(first_doc_ref.clone())) + )) + .build(); + let last_doc_ref = DocumentRef::try_from(&last).unwrap(); + + provider.add_document(Some(first_doc_ref), &first).unwrap(); + provider.add_document(Some(last_doc_ref), &last).unwrap(); + + (provider, last) + } => false; + "not the same type as the chained document" +)] +#[test_case( + { + let doc_type = UuidV4::new(); + let doc_id = UuidV7::new(); + + let mut provider = TestCatalystProvider::default(); + + let first_doc_ver = UuidV7::new(); + let first = Builder::new() + .with_metadata_field(SupportedField::Type(DocType::from(doc_type))) + .with_metadata_field(SupportedField::Id(doc_id)) + .with_metadata_field(SupportedField::Ver(first_doc_ver)) + .with_metadata_field(SupportedField::Chain( + Chain::new(0, None) + )) + .build(); + let first_doc_ref = DocumentRef::try_from(&first).unwrap(); + + let last_doc_ver = helper::get_now_plus_uuidv7(60); + let last = Builder::new() + .with_metadata_field(SupportedField::Type(DocType::from(doc_type))) + .with_metadata_field(SupportedField::Id(doc_id)) + .with_metadata_field(SupportedField::Ver(last_doc_ver)) + .with_metadata_field(SupportedField::Chain( + // -2 + Chain::new(-2, Some(first_doc_ref.clone())) + )) + .build(); + let last_doc_ref = DocumentRef::try_from(&last).unwrap(); + + provider.add_document(Some(first_doc_ref), &first).unwrap(); + provider.add_document(Some(last_doc_ref), &last).unwrap(); + + (provider, last) + } => false; + "not have its absolute height exactly one more than the height of the document being chained to" +)] +#[tokio::test] +async fn test_invalid_chained_documents( + (provider, doc): (TestCatalystProvider, CatalystSignedDocument) +) -> bool { + let rule = ChainRule::Specified { optional: false }; + + rule.check(&doc, &provider).await.unwrap() +} diff --git a/rust/signed_doc/src/validator/rules/mod.rs b/rust/signed_doc/src/validator/rules/mod.rs index 89e6982b202..2be72cf4cbd 100644 --- a/rust/signed_doc/src/validator/rules/mod.rs +++ b/rust/signed_doc/src/validator/rules/mod.rs @@ -8,6 +8,7 @@ use crate::{ CatalystSignedDocument, }; +mod chain; mod collaborators; mod content; mod content_encoding; @@ -24,6 +25,7 @@ mod template; mod utils; mod ver; +pub(crate) use chain::ChainRule; pub(crate) use collaborators::CollaboratorsRule; pub(crate) use content::ContentRule; pub(crate) use content_encoding::ContentEncodingRule; @@ -60,6 +62,8 @@ pub(crate) struct Rules { pub(crate) section: SectionRule, /// 'parameters' field validation rule pub(crate) parameters: ParametersRule, + /// 'chain' field validation rule + pub(crate) chain: ChainRule, /// 'collaborators' field validation rule pub(crate) collaborators: CollaboratorsRule, /// document's content validation rule @@ -92,6 +96,7 @@ impl Rules { self.reply.check(doc, provider).boxed(), self.section.check(doc).boxed(), self.parameters.check(doc, provider).boxed(), + self.chain.check(doc, provider).boxed(), self.collaborators.check(doc).boxed(), self.content.check(doc).boxed(), self.kid.check(doc).boxed(), @@ -133,6 +138,7 @@ impl Rules { content_encoding: ContentEncodingRule::new(&doc_spec.headers.content_encoding)?, template: TemplateRule::new(&spec.docs, &doc_spec.metadata.template)?, parameters: ParametersRule::new(&spec.docs, &doc_spec.metadata.parameters)?, + chain: ChainRule::new(&spec.docs, &doc_spec.metadata.chain), doc_ref: RefRule::new(&spec.docs, &doc_spec.metadata.doc_ref)?, reply: ReplyRule::new(&spec.docs, &doc_spec.metadata.reply)?, section: SectionRule::NotSpecified, diff --git a/rust/signed_doc/src/validator/rules/parameters/mod.rs b/rust/signed_doc/src/validator/rules/parameters/mod.rs index a07195048dc..3c4016ae492 100644 --- a/rust/signed_doc/src/validator/rules/parameters/mod.rs +++ b/rust/signed_doc/src/validator/rules/parameters/mod.rs @@ -120,12 +120,26 @@ impl ParametersRule { doc.report(), ) .boxed(); + let chain_field = doc + .doc_meta() + .chain() + .and_then(|v| v.document_ref().cloned()) + .map(|v| vec![v].into()); + let chain_link_check = link_check( + chain_field.as_ref(), + parameters_ref, + "chain", + provider, + doc.report(), + ) + .boxed(); let checks = [ parameters_check, template_link_check, ref_link_check, reply_link_check, + chain_link_check, ]; let res = futures::future::join_all(checks) .await @@ -158,7 +172,24 @@ impl ParametersRule { } } -/// Parameter Link reference check +/// Performs a parameter link validation between a given reference field and the expected +/// parameters. +/// +/// Validates that all referenced documents +/// have matching `parameters` with the current document's expected `exp_parameters`. +/// +/// # Returns +/// - `Ok(true)` if: +/// - `ref_field` is `None`, or +/// - all referenced documents are successfully retrieved **and** each has a +/// `parameters` field that matches `exp_parameters`. +/// +/// - `Ok(false)` if: +/// - any referenced document cannot be retrieved, +/// - a referenced document is missing its `parameters` field, or +/// - the parameters mismatch the expected ones. +/// +/// - `Err(anyhow::Error)` if an unexpected error occurs while accessing the provider. pub(crate) async fn link_check( ref_field: Option<&DocumentRefs>, exp_parameters: &DocumentRefs,