diff --git a/rust/catalyst-types/src/uuid/uuid_v7.rs b/rust/catalyst-types/src/uuid/uuid_v7.rs index 1bb95e6ff9..f77accc7aa 100644 --- a/rust/catalyst-types/src/uuid/uuid_v7.rs +++ b/rust/catalyst-types/src/uuid/uuid_v7.rs @@ -10,7 +10,7 @@ use uuid::Uuid; use super::{decode_cbor_uuid, encode_cbor_uuid, CborContext, UuidError, INVALID_UUID}; /// Type representing a `UUIDv7`. -#[derive(Copy, Clone, Debug, PartialEq, PartialOrd, serde::Serialize)] +#[derive(Copy, Clone, Debug, PartialEq, PartialOrd, Eq, Hash, serde::Serialize)] pub struct UuidV7(Uuid); impl UuidV7 { diff --git a/rust/signed_doc/src/lib.rs b/rust/signed_doc/src/lib.rs index ea42acd230..fb638904fa 100644 --- a/rust/signed_doc/src/lib.rs +++ b/rust/signed_doc/src/lib.rs @@ -23,7 +23,9 @@ pub use catalyst_types::{ pub use content::Content; use coset::{CborSerializable, TaggedCborSerializable}; use decode_context::{CompatibilityPolicy, DecodeContext}; -pub use metadata::{ContentEncoding, ContentType, DocType, DocumentRef, Metadata, Section}; +pub use metadata::{ + ContentEncoding, ContentType, DocLocator, DocType, DocumentRef, DocumentRefs, Metadata, Section, +}; use minicbor::{decode, encode, Decode, Decoder, Encode}; pub use signature::{CatalystId, Signatures}; diff --git a/rust/signed_doc/src/metadata/document_ref.rs b/rust/signed_doc/src/metadata/document_ref.rs deleted file mode 100644 index 612a80bbf5..0000000000 --- a/rust/signed_doc/src/metadata/document_ref.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! Catalyst Signed Document Metadata. - -use std::fmt::Display; - -use coset::cbor::Value; - -use super::{utils::CborUuidV7, UuidV7}; - -/// Reference to a Document. -#[derive(Copy, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct DocumentRef { - /// Reference to the Document Id - pub id: UuidV7, - /// Reference to the Document Ver - pub ver: UuidV7, -} - -impl Display for DocumentRef { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "id: {}, ver: {}", self.id, self.ver) - } -} - -impl TryFrom<&Value> for DocumentRef { - type Error = anyhow::Error; - - #[allow(clippy::indexing_slicing)] - fn try_from(val: &Value) -> anyhow::Result { - let Some(array) = val.as_array() else { - anyhow::bail!("Document Reference must be either a single UUID or an array of two"); - }; - anyhow::ensure!( - array.len() == 2, - "Document Reference array of two UUIDs was expected" - ); - let CborUuidV7(id) = CborUuidV7::try_from(&array[0])?; - let CborUuidV7(ver) = CborUuidV7::try_from(&array[1])?; - anyhow::ensure!( - ver >= id, - "Document Reference Version can never be smaller than its ID" - ); - Ok(DocumentRef { id, ver }) - } -} - -impl minicbor::Encode<()> for DocumentRef { - fn encode( - &self, e: &mut minicbor::Encoder, _ctx: &mut (), - ) -> Result<(), minicbor::encode::Error> { - e.array(2)? - .encode_with(self.id, &mut catalyst_types::uuid::CborContext::Tagged)? - .encode_with(self.ver, &mut catalyst_types::uuid::CborContext::Tagged)?; - Ok(()) - } -} diff --git a/rust/signed_doc/src/metadata/document_refs/doc_locator.rs b/rust/signed_doc/src/metadata/document_refs/doc_locator.rs new file mode 100644 index 0000000000..61e6bac229 --- /dev/null +++ b/rust/signed_doc/src/metadata/document_refs/doc_locator.rs @@ -0,0 +1,175 @@ +//! Document Locator, where a document can be located. +//! A [CBOR Encoded IPLD Content Identifier](https://github.com/ipld/cid-cbor/) +//! or also known as [IPFS CID](https://docs.ipfs.tech/concepts/content-addressing/#what-is-a-cid). + +use std::fmt::Display; + +use catalyst_types::problem_report::ProblemReport; +use coset::cbor::Value; +use minicbor::{Decode, Decoder, Encode}; + +/// CBOR tag of IPLD content identifiers (CIDs). +const CID_TAG: u64 = 42; + +/// CID map key. +const CID_MAP_KEY: &str = "cid"; + +/// Document locator number of map item. +const DOC_LOC_MAP_ITEM: u64 = 1; + +/// Document locator, no size limit. +#[derive(Clone, Debug, Default, PartialEq, Hash, Eq, serde::Serialize)] +pub struct DocLocator(Vec); + +impl DocLocator { + #[must_use] + /// Length of the document locator. + pub fn len(&self) -> usize { + self.0.len() + } + + #[must_use] + /// Is the document locator empty. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl From> for DocLocator { + fn from(value: Vec) -> Self { + DocLocator(value) + } +} + +impl Display for DocLocator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "cid: 0x{}", hex::encode(self.0.as_slice())) + } +} + +impl From for Value { + fn from(value: DocLocator) -> Self { + Value::Map(vec![( + Value::Text(CID_MAP_KEY.to_string()), + Value::Tag(CID_TAG, Box::new(Value::Bytes(value.0.clone()))), + )]) + } +} + +// document_locator = { "cid" => cid } +impl Decode<'_, ProblemReport> for DocLocator { + fn decode( + d: &mut Decoder, report: &mut ProblemReport, + ) -> Result { + const CONTEXT: &str = "DocLocator decoding"; + + let len = d.map()?.ok_or_else(|| { + report.invalid_value("Map", "Invalid length", "Valid length", CONTEXT); + minicbor::decode::Error::message(format!("{CONTEXT}: expected valid map length")) + })?; + + if len != DOC_LOC_MAP_ITEM { + report.invalid_value( + "Map length", + &len.to_string(), + &DOC_LOC_MAP_ITEM.to_string(), + CONTEXT, + ); + return Err(minicbor::decode::Error::message(format!( + "{CONTEXT}: expected map length {DOC_LOC_MAP_ITEM}, found {len}" + ))); + } + + let key = d.str().map_err(|e| { + report.invalid_value("Key", "Not a string", "String", CONTEXT); + e.with_message(format!("{CONTEXT}: expected string")) + })?; + + if key != "cid" { + report.invalid_value("Key", key, "'cid'", CONTEXT); + return Err(minicbor::decode::Error::message(format!( + "{CONTEXT}: expected key 'cid', found '{key}'" + ))); + } + + let tag = d.tag().map_err(|e| { + report.invalid_value("CBOR tag", "Invalid tag", "Valid tag", CONTEXT); + e.with_message(format!("{CONTEXT}: expected tag")) + })?; + + if tag.as_u64() != CID_TAG { + report.invalid_value("CBOR tag", &tag.to_string(), &CID_TAG.to_string(), CONTEXT); + return Err(minicbor::decode::Error::message(format!( + "{CONTEXT}: expected tag {CID_TAG}, found {tag}", + ))); + } + + // No length limit + let cid_bytes = d.bytes().map_err(|e| { + report.invalid_value("CID bytes", "Invalid bytes", "Valid bytes", CONTEXT); + e.with_message(format!("{CONTEXT}: expected bytes")) + })?; + + Ok(DocLocator(cid_bytes.to_vec())) + } +} + +impl Encode<()> for DocLocator { + fn encode( + &self, e: &mut minicbor::Encoder, (): &mut (), + ) -> Result<(), minicbor::encode::Error> { + e.map(DOC_LOC_MAP_ITEM)?; + e.str(CID_MAP_KEY)?; + e.tag(minicbor::data::Tag::new(CID_TAG))?; + e.bytes(&self.0)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + + use minicbor::{Decoder, Encoder}; + + use super::*; + + #[test] + fn test_doc_locator_encode_decode() { + let mut report = ProblemReport::new("Test doc locator"); + let locator = DocLocator(vec![1, 2, 3, 4]); + let mut buffer = Vec::new(); + let mut encoder = Encoder::new(&mut buffer); + locator.encode(&mut encoder, &mut ()).unwrap(); + let mut decoder = Decoder::new(&buffer); + let decoded_doc_loc = DocLocator::decode(&mut decoder, &mut report).unwrap(); + assert_eq!(locator, decoded_doc_loc); + } + + // Empty doc locator should not fail + #[test] + fn test_doc_locator_encode_decode_empty() { + let mut report = ProblemReport::new("Test doc locator empty"); + let locator = DocLocator(vec![]); + let mut buffer = Vec::new(); + let mut encoder = Encoder::new(&mut buffer); + locator.encode(&mut encoder, &mut ()).unwrap(); + let mut decoder = Decoder::new(&buffer); + let decoded_doc_loc = DocLocator::decode(&mut decoder, &mut report).unwrap(); + assert_eq!(locator, decoded_doc_loc); + } + + #[test] + #[allow(clippy::indexing_slicing)] + fn test_doc_locator_to_value() { + let data = vec![1, 2, 3, 4]; + let locator = DocLocator(data.clone()); + let value: Value = locator.into(); + let map = value.into_map().unwrap(); + assert_eq!(map.len(), usize::try_from(DOC_LOC_MAP_ITEM).unwrap()); + let key = map[0].0.clone().into_text().unwrap(); + assert_eq!(key, CID_MAP_KEY); + let (tag, value) = map[0].1.clone().into_tag().unwrap(); + assert_eq!(tag, CID_TAG); + assert_eq!(value.into_bytes().unwrap(), data); + } +} diff --git a/rust/signed_doc/src/metadata/document_refs/doc_ref.rs b/rust/signed_doc/src/metadata/document_refs/doc_ref.rs new file mode 100644 index 0000000000..2339fc6450 --- /dev/null +++ b/rust/signed_doc/src/metadata/document_refs/doc_ref.rs @@ -0,0 +1,171 @@ +//! Document reference. + +use std::fmt::Display; + +use catalyst_types::uuid::{CborContext, UuidV7}; +use coset::cbor::Value; +use minicbor::{Decode, Decoder, Encode}; + +use super::{doc_locator::DocLocator, DocRefError}; +use crate::{metadata::utils::CborUuidV7, DecodeContext}; + +/// Number of item that should be in each document reference instance. +const DOC_REF_ARR_ITEM: u64 = 3; + +/// Reference to a Document. +#[derive(Clone, Debug, PartialEq, Hash, Eq, serde::Serialize)] +pub struct DocumentRef { + /// Reference to the Document Id + id: UuidV7, + /// Reference to the Document Ver + ver: UuidV7, + /// Document locator + doc_locator: DocLocator, +} + +impl DocumentRef { + /// Create a new instance of document reference. + #[must_use] + pub fn new(id: UuidV7, ver: UuidV7, doc_locator: DocLocator) -> Self { + Self { + id, + ver, + doc_locator, + } + } + + /// Get Document Id. + #[must_use] + pub fn id(&self) -> &UuidV7 { + &self.id + } + + /// Get Document Ver. + #[must_use] + pub fn ver(&self) -> &UuidV7 { + &self.ver + } + + /// Get Document Locator. + #[must_use] + pub fn doc_locator(&self) -> &DocLocator { + &self.doc_locator + } +} + +impl Display for DocumentRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "id: {}, ver: {}, document_locator: {}", + self.id, self.ver, self.doc_locator + ) + } +} + +impl TryFrom for Value { + type Error = DocRefError; + + fn try_from(value: DocumentRef) -> Result { + let id = Value::try_from(CborUuidV7(value.id)) + .map_err(|_| DocRefError::InvalidUuidV7(value.id, "id".to_string()))?; + + let ver = Value::try_from(CborUuidV7(value.ver)) + .map_err(|_| DocRefError::InvalidUuidV7(value.ver, "ver".to_string()))?; + + let locator = value.doc_locator.clone().into(); + + Ok(Value::Array(vec![id, ver, locator])) + } +} + +impl Decode<'_, DecodeContext<'_>> for DocumentRef { + fn decode( + d: &mut minicbor::Decoder<'_>, decode_context: &mut DecodeContext<'_>, + ) -> Result { + const CONTEXT: &str = "DocumentRef decoding"; + let parse_uuid = |d: &mut Decoder| UuidV7::decode(d, &mut CborContext::Tagged); + + let arr = d.array()?.ok_or_else(|| { + decode_context + .report + .other("Unable to decode array length", CONTEXT); + minicbor::decode::Error::message(format!("{CONTEXT}: Unable to decode array length")) + })?; + if arr != DOC_REF_ARR_ITEM { + decode_context.report.invalid_value( + "Array length", + &arr.to_string(), + &DOC_REF_ARR_ITEM.to_string(), + CONTEXT, + ); + return Err(minicbor::decode::Error::message(format!( + "{CONTEXT}: expected {DOC_REF_ARR_ITEM} items, found {arr}" + ))); + } + let id = parse_uuid(d).map_err(|e| { + decode_context + .report + .other(&format!("Invalid ID UUIDv7: {e}"), CONTEXT); + e.with_message("Invalid ID UUIDv7") + })?; + + let ver = parse_uuid(d).map_err(|e| { + decode_context + .report + .other(&format!("Invalid Ver UUIDv7: {e}"), CONTEXT); + e.with_message("Invalid Ver UUIDv7") + })?; + + let locator = DocLocator::decode(d, decode_context.report).map_err(|e| { + decode_context + .report + .other(&format!("Failed to decode locator {e}"), CONTEXT); + e.with_message("Failed to decode locator") + })?; + + Ok(DocumentRef { + id, + ver, + doc_locator: locator, + }) + } +} + +impl Encode<()> for DocumentRef { + fn encode( + &self, e: &mut minicbor::Encoder, ctx: &mut (), + ) -> Result<(), minicbor::encode::Error> { + e.array(DOC_REF_ARR_ITEM)?; + self.id.encode(e, &mut CborContext::Tagged)?; + self.ver.encode(e, &mut CborContext::Tagged)?; + self.doc_locator.encode(e, ctx)?; + Ok(()) + } +} + +#[cfg(test)] +mod test { + use catalyst_types::uuid::{UuidV7, UUID_CBOR_TAG}; + use coset::cbor::Value; + + use crate::metadata::document_refs::{doc_ref::DOC_REF_ARR_ITEM, DocumentRef}; + + #[test] + #[allow(clippy::indexing_slicing)] + fn test_doc_refs_to_value() { + let uuidv7 = UuidV7::new(); + let doc_ref = DocumentRef::new(uuidv7, uuidv7, vec![1, 2, 3].into()); + let value: Value = doc_ref.try_into().unwrap(); + let arr = value.into_array().unwrap(); + assert_eq!(arr.len(), usize::try_from(DOC_REF_ARR_ITEM).unwrap()); + let (id_tag, value) = arr[0].clone().into_tag().unwrap(); + assert_eq!(id_tag, UUID_CBOR_TAG); + assert_eq!(value.as_bytes().unwrap().len(), 16); + let (ver_tag, value) = arr[1].clone().into_tag().unwrap(); + assert_eq!(ver_tag, UUID_CBOR_TAG); + assert_eq!(value.as_bytes().unwrap().len(), 16); + let map = arr[2].clone().into_map().unwrap(); + assert_eq!(map.len(), 1); + } +} diff --git a/rust/signed_doc/src/metadata/document_refs/mod.rs b/rust/signed_doc/src/metadata/document_refs/mod.rs new file mode 100644 index 0000000000..3c3cf6704f --- /dev/null +++ b/rust/signed_doc/src/metadata/document_refs/mod.rs @@ -0,0 +1,413 @@ +//! Document references. + +mod doc_locator; +mod doc_ref; +use std::{fmt::Display, str::FromStr}; + +use catalyst_types::uuid::{CborContext, UuidV7}; +use coset::cbor::Value; +pub use doc_locator::DocLocator; +pub use doc_ref::DocumentRef; +use minicbor::{Decode, Decoder, Encode}; +use serde::{Deserialize, Deserializer}; +use tracing::warn; + +use crate::{CompatibilityPolicy, DecodeContext}; + +/// List of document reference instance. +#[derive(Clone, Debug, PartialEq, Hash, Eq, serde::Serialize)] +pub struct DocumentRefs(Vec); + +/// Document reference error. +#[derive(Debug, Clone, thiserror::Error)] +pub enum DocRefError { + /// Invalid `UUIDv7`. + #[error("Invalid UUID: {0} for field {1}")] + InvalidUuidV7(UuidV7, String), + /// `DocRef` cannot be empty. + #[error("DocType cannot be empty")] + Empty, + /// Invalid string conversion + #[error("Invalid string conversion: {0}")] + StringConversion(String), + /// Cannot decode hex. + #[error("Cannot decode hex: {0}")] + HexDecode(String), +} + +impl DocumentRefs { + /// Get a list of document reference instance. + #[must_use] + pub fn doc_refs(&self) -> &Vec { + &self.0 + } +} + +impl From> for DocumentRefs { + fn from(value: Vec) -> Self { + DocumentRefs(value) + } +} + +impl Display for DocumentRefs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let items = self + .0 + .iter() + .map(|inner| format!("{inner}")) + .collect::>() + .join(", "); + write!(f, "[{items}]") + } +} + +impl Decode<'_, DecodeContext<'_>> for DocumentRefs { + fn decode( + d: &mut minicbor::Decoder<'_>, decode_context: &mut DecodeContext<'_>, + ) -> Result { + const CONTEXT: &str = "DocumentRefs decoding"; + let parse_uuid = |d: &mut Decoder| UuidV7::decode(d, &mut CborContext::Tagged); + + // Old: [id, ver] + // New: [ 1* [id, ver, locator] ] + let outer_arr = d.array()?.ok_or_else(|| { + decode_context.report.invalid_value( + "Array", + "Invalid array length", + "Valid array length", + CONTEXT, + ); + minicbor::decode::Error::message(format!("{CONTEXT}: expected valid array length")) + })?; + + match d.datatype()? { + // New structure inner part [id, ver, locator] + minicbor::data::Type::Array => { + let mut doc_refs = vec![]; + for _ in 0..outer_arr { + let doc_ref = DocumentRef::decode(d, decode_context)?; + doc_refs.push(doc_ref); + } + Ok(DocumentRefs(doc_refs)) + }, + // Old structure [id, ver] + minicbor::data::Type::Tag => { + match decode_context.compatibility_policy { + CompatibilityPolicy::Accept | CompatibilityPolicy::Warn => { + if matches!( + decode_context.compatibility_policy, + CompatibilityPolicy::Warn + ) { + warn!("{CONTEXT}: Conversion of document reference, id and version, to list of document reference with doc locator"); + } + let id = parse_uuid(d).map_err(|e| { + decode_context + .report + .other(&format!("Invalid ID UUIDv7: {e}"), CONTEXT); + e.with_message("Invalid ID UUIDv7") + })?; + let ver = parse_uuid(d).map_err(|e| { + decode_context + .report + .other(&format!("Invalid Ver UUIDv7: {e}"), CONTEXT); + e.with_message("Invalid Ver UUIDv7") + })?; + + Ok(DocumentRefs(vec![DocumentRef::new( + id, + ver, + // If old implementation is used, the locator will be empty + DocLocator::default(), + )])) + }, + CompatibilityPolicy::Fail => { + let msg = "Conversion of document reference id and version to list of document reference with doc locator is not allowed"; + decode_context.report.other(msg, CONTEXT); + Err(minicbor::decode::Error::message(format!( + "{CONTEXT}: {msg}" + ))) + }, + } + }, + other => { + decode_context.report.invalid_value( + "Decoding type", + &other.to_string(), + "Array or tag", + CONTEXT, + ); + Err(minicbor::decode::Error::message(format!( + "{CONTEXT}: Expected array of document reference, or tag of version and id, found {other}" + ))) + }, + } + } +} + +impl Encode<()> for DocumentRefs { + fn encode( + &self, e: &mut minicbor::Encoder, ctx: &mut (), + ) -> Result<(), minicbor::encode::Error> { + const CONTEXT: &str = "DocumentRefs encoding"; + if self.0.is_empty() { + return Err(minicbor::encode::Error::message(format!( + "{CONTEXT}: DocumentRefs cannot be empty" + ))); + } + e.array( + self.0 + .len() + .try_into() + .map_err(|e| minicbor::encode::Error::message(format!("{CONTEXT}, {e}")))?, + )?; + + for doc_ref in &self.0 { + doc_ref.encode(e, ctx)?; + } + Ok(()) + } +} + +impl TryFrom for Value { + type Error = DocRefError; + + fn try_from(value: DocumentRefs) -> Result { + if value.0.is_empty() { + return Err(DocRefError::Empty); + } + + let array_values: Result, Self::Error> = value + .0 + .iter() + .map(|inner| Value::try_from(inner.to_owned())) + .collect(); + + Ok(Value::Array(array_values?)) + } +} + +impl TryFrom<&DocumentRefs> for Value { + type Error = DocRefError; + + fn try_from(value: &DocumentRefs) -> Result { + value.clone().try_into() + } +} + +impl<'de> Deserialize<'de> for DocumentRefs { + fn deserialize(deserializer: D) -> Result + where D: Deserializer<'de> { + /// Old structure deserialize as map {id, ver} + #[derive(Deserialize)] + struct OldRef { + /// "id": "uuidv7 + id: String, + /// "ver": "uuidv7" + ver: String, + } + + /// New structure as deserialize as map {id, ver, cid} + #[derive(Deserialize)] + struct NewRef { + /// "id": "uuidv7" + id: String, + /// "ver": "uuidv7" + ver: String, + /// "cid": "0x..." + cid: String, + } + + #[derive(Deserialize)] + #[serde(untagged)] + enum DocRefInput { + /// Old structure of document reference. + Old(OldRef), + /// New structure of document reference. + New(Vec), + } + + let input = DocRefInput::deserialize(deserializer)?; + let dr = match input { + DocRefInput::Old(value) => { + let id = UuidV7::from_str(&value.id).map_err(|_| { + serde::de::Error::custom(DocRefError::StringConversion(value.id.clone())) + })?; + let ver = UuidV7::from_str(&value.ver).map_err(|_| { + serde::de::Error::custom(DocRefError::StringConversion(value.ver.clone())) + })?; + + DocumentRefs(vec![DocumentRef::new(id, ver, DocLocator::default())]) + }, + DocRefInput::New(value) => { + let mut dr = vec![]; + for v in value { + let id = UuidV7::from_str(&v.id).map_err(|_| { + serde::de::Error::custom(DocRefError::StringConversion(v.id.clone())) + })?; + let ver = UuidV7::from_str(&v.ver).map_err(|_| { + serde::de::Error::custom(DocRefError::StringConversion(v.ver.clone())) + })?; + let cid = &v.cid.strip_prefix("0x").unwrap_or(&v.cid); + let locator = hex::decode(cid).map_err(|_| { + serde::de::Error::custom(DocRefError::HexDecode(v.cid.clone())) + })?; + dr.push(DocumentRef::new(id, ver, locator.into())); + } + DocumentRefs(dr) + }, + }; + + Ok(dr) + } +} + +#[cfg(test)] +mod tests { + + use catalyst_types::problem_report::ProblemReport; + use minicbor::Encoder; + use serde_json::json; + + use super::*; + + #[allow(clippy::unwrap_used)] + fn gen_old_doc_ref(id: UuidV7, ver: UuidV7) -> Vec { + let mut buffer = Vec::new(); + let mut encoder = Encoder::new(&mut buffer); + encoder.array(2).unwrap(); + id.encode(&mut encoder, &mut CborContext::Tagged).unwrap(); + ver.encode(&mut encoder, &mut CborContext::Tagged).unwrap(); + buffer + } + + #[test] + fn test_old_doc_refs_fail_policy_cbor_decode() { + let mut report = ProblemReport::new("Test doc ref fail policy"); + let mut decoded_context = DecodeContext { + compatibility_policy: CompatibilityPolicy::Fail, + report: &mut report, + }; + let uuidv7 = UuidV7::new(); + let old_doc_ref = gen_old_doc_ref(uuidv7, uuidv7); + let decoder = Decoder::new(&old_doc_ref); + assert!(DocumentRefs::decode(&mut decoder.clone(), &mut decoded_context).is_err()); + } + + #[test] + fn test_old_doc_refs_warn_policy_cbor_decode() { + let mut report = ProblemReport::new("Test doc ref warn policy"); + let mut decoded_context = DecodeContext { + compatibility_policy: CompatibilityPolicy::Warn, + report: &mut report, + }; + let uuidv7 = UuidV7::new(); + let old_doc_ref = gen_old_doc_ref(uuidv7, uuidv7); + let decoder = Decoder::new(&old_doc_ref); + let decoded_doc_ref = + DocumentRefs::decode(&mut decoder.clone(), &mut decoded_context).unwrap(); + assert_eq!(decoded_doc_ref.doc_refs().len(), 1); + assert_eq!( + decoded_doc_ref + .doc_refs() + .first() + .unwrap() + .doc_locator() + .len(), + 0 + ); + } + + #[test] + fn test_old_doc_refs_accept_policy_cbor_decode() { + let mut report = ProblemReport::new("Test doc ref accept policy"); + let mut decoded_context = DecodeContext { + compatibility_policy: CompatibilityPolicy::Accept, + report: &mut report, + }; + let uuidv7 = UuidV7::new(); + let old_doc_ref = gen_old_doc_ref(uuidv7, uuidv7); + let decoder = Decoder::new(&old_doc_ref); + let decoded_doc_ref = + DocumentRefs::decode(&mut decoder.clone(), &mut decoded_context).unwrap(); + assert_eq!(decoded_doc_ref.doc_refs().len(), 1); + assert_eq!( + decoded_doc_ref + .doc_refs() + .first() + .unwrap() + .doc_locator() + .len(), + 0 + ); + } + + #[test] + fn test_doc_refs_cbor_encode_decode() { + let mut report = ProblemReport::new("Test doc refs"); + + let uuidv7 = UuidV7::new(); + let doc_ref = DocumentRef::new(uuidv7, uuidv7, vec![1, 2, 3, 4].into()); + let doc_refs = DocumentRefs(vec![doc_ref.clone(), doc_ref]); + let mut buffer = Vec::new(); + let mut encoder = Encoder::new(&mut buffer); + doc_refs.encode(&mut encoder, &mut ()).unwrap(); + let mut decoder = Decoder::new(&buffer); + let mut decoded_context = DecodeContext { + compatibility_policy: CompatibilityPolicy::Accept, + report: &mut report, + }; + let decoded_doc_refs = DocumentRefs::decode(&mut decoder, &mut decoded_context).unwrap(); + assert_eq!(decoded_doc_refs, doc_refs); + } + + #[test] + fn test_doc_refs_to_value() { + let uuidv7 = UuidV7::new(); + let doc_ref = DocumentRef::new(uuidv7, uuidv7, vec![1, 2, 3].into()); + let doc_ref = DocumentRefs(vec![doc_ref.clone(), doc_ref]); + let value: Value = doc_ref.try_into().unwrap(); + assert_eq!(value.as_array().unwrap().len(), 2); + } + + #[test] + fn test_deserialize_old_doc_ref() { + let uuidv7 = UuidV7::new(); + let json = json!( + { + "id": uuidv7.to_string(), + "ver": uuidv7.to_string(), + } + ); + let doc_ref: DocumentRefs = serde_json::from_value(json).unwrap(); + let dr = doc_ref.doc_refs().first().unwrap(); + assert_eq!(*dr.id(), uuidv7); + assert_eq!(*dr.ver(), uuidv7); + assert_eq!(dr.doc_locator().len(), 0); + } + + #[test] + fn test_deserialize_new_doc_ref() { + let uuidv7 = UuidV7::new(); + let data = vec![1, 2, 3, 4]; + let hex_data = format!("0x{}", hex::encode(data.clone())); + let json = json!( + [{ + "id": uuidv7.to_string(), + "ver": uuidv7.to_string(), + "cid": hex_data, + }, + { + "id": uuidv7.to_string(), + "ver": uuidv7.to_string(), + "cid": hex_data, + }, + ] + ); + let doc_ref: DocumentRefs = serde_json::from_value(json).unwrap(); + assert!(doc_ref.doc_refs().len() == 2); + let dr = doc_ref.doc_refs().first().unwrap(); + assert_eq!(*dr.id(), uuidv7); + assert_eq!(*dr.ver(), uuidv7); + assert_eq!(*dr.doc_locator(), data.into()); + } +} diff --git a/rust/signed_doc/src/metadata/mod.rs b/rust/signed_doc/src/metadata/mod.rs index 4c7b46527e..06f393fdea 100644 --- a/rust/signed_doc/src/metadata/mod.rs +++ b/rust/signed_doc/src/metadata/mod.rs @@ -8,7 +8,7 @@ use std::{ mod content_encoding; mod content_type; pub(crate) mod doc_type; -mod document_ref; +mod document_refs; mod section; mod supported_field; pub(crate) mod utils; @@ -16,17 +16,19 @@ pub(crate) mod utils; use catalyst_types::{problem_report::ProblemReport, uuid::UuidV7}; pub use content_encoding::ContentEncoding; pub use content_type::ContentType; -use coset::CborSerializable; pub use doc_type::DocType; -pub use document_ref::DocumentRef; -use minicbor::{Decode, Decoder}; +pub use document_refs::{DocLocator, DocumentRef, DocumentRefs}; +use minicbor::Decoder; pub use section::Section; use strum::IntoDiscriminant as _; use utils::{cose_protected_header_find, decode_document_field_from_protected_header, CborUuidV7}; use crate::{ decode_context::DecodeContext, - metadata::supported_field::{SupportedField, SupportedLabel}, + metadata::{ + supported_field::{SupportedField, SupportedLabel}, + utils::decode_cose_protected_header_value, + }, }; /// `content_encoding` field COSE key value @@ -122,29 +124,26 @@ impl Metadata { /// Return `ref` field. #[must_use] - pub fn doc_ref(&self) -> Option { + pub fn doc_ref(&self) -> Option<&DocumentRefs> { self.0 .get(&SupportedLabel::Ref) .and_then(SupportedField::try_as_ref_ref) - .copied() } /// Return `template` field. #[must_use] - pub fn template(&self) -> Option { + pub fn template(&self) -> Option<&DocumentRefs> { self.0 .get(&SupportedLabel::Template) .and_then(SupportedField::try_as_template_ref) - .copied() } /// Return `reply` field. #[must_use] - pub fn reply(&self) -> Option { + pub fn reply(&self) -> Option<&DocumentRefs> { self.0 .get(&SupportedLabel::Reply) .and_then(SupportedField::try_as_reply_ref) - .copied() } /// Return `section` field. @@ -166,11 +165,10 @@ impl Metadata { /// Return `parameters` field. #[must_use] - pub fn parameters(&self) -> Option { + pub fn parameters(&self) -> Option<&DocumentRefs> { self.0 .get(&SupportedLabel::Parameters) .and_then(SupportedField::try_as_parameters_ref) - .copied() } /// Build `Metadata` object from the metadata fields, doing all necessary validation. @@ -266,20 +264,6 @@ impl Metadata { } } - if let Some(value) = cose_protected_header_find( - protected, - |key| matches!(key, coset::Label::Text(label) if label.eq_ignore_ascii_case(TYPE_KEY)), - ) - .and_then(|value| { - DocType::decode( - &mut Decoder::new(&value.clone().to_vec().unwrap_or_default()), - context, - ) - .ok() - }) { - metadata_fields.push(SupportedField::Type(value)); - } - if let Some(value) = decode_document_field_from_protected_header::( protected, ID_KEY, @@ -302,30 +286,30 @@ impl Metadata { metadata_fields.push(SupportedField::Ver(value)); } - if let Some(value) = decode_document_field_from_protected_header( - protected, - REF_KEY, - COSE_DECODING_CONTEXT, - context.report, + // DocType and DocRef now using minicbor decoding. + if let Some(value) = decode_cose_protected_header_value::( + protected, context, TYPE_KEY, + ) { + metadata_fields.push(SupportedField::Type(value)); + }; + if let Some(value) = decode_cose_protected_header_value::( + protected, context, REF_KEY, ) { metadata_fields.push(SupportedField::Ref(value)); - } - if let Some(value) = decode_document_field_from_protected_header( + }; + if let Some(value) = decode_cose_protected_header_value::( protected, + context, TEMPLATE_KEY, - COSE_DECODING_CONTEXT, - context.report, ) { metadata_fields.push(SupportedField::Template(value)); } - if let Some(value) = decode_document_field_from_protected_header( - protected, - REPLY_KEY, - COSE_DECODING_CONTEXT, - context.report, + if let Some(value) = decode_cose_protected_header_value::( + protected, context, REPLY_KEY, ) { metadata_fields.push(SupportedField::Reply(value)); } + if let Some(value) = decode_document_field_from_protected_header( protected, SECTION_KEY, @@ -343,20 +327,15 @@ impl Metadata { CATEGORY_ID_KEY, ] .iter() - .filter_map(|field_name| -> Option { - decode_document_field_from_protected_header( - protected, - field_name, - COSE_DECODING_CONTEXT, - context.report, - ) + .filter_map(|field_name| -> Option { + decode_cose_protected_header_value(protected, context, field_name) }) .fold((None, false), |(res, _), v| (Some(v), res.is_some())); if has_multiple_fields { context.report.duplicate_field( - "brand_id, campaign_id, category_id", - "Only value at the same time is allowed parameters, brand_id, campaign_id, category_id", - "Validation of parameters field aliases" + "Parameters field", + "Only one parameter can be used at a time: either brand_id, campaign_id, category_id", + COSE_DECODING_CONTEXT ); } if let Some(value) = parameters { diff --git a/rust/signed_doc/src/metadata/supported_field.rs b/rust/signed_doc/src/metadata/supported_field.rs index 69f1c589cb..c0479320f0 100644 --- a/rust/signed_doc/src/metadata/supported_field.rs +++ b/rust/signed_doc/src/metadata/supported_field.rs @@ -9,7 +9,7 @@ use serde::Deserialize; use strum::{EnumDiscriminants, EnumTryAs, IntoDiscriminant as _}; use crate::{ - metadata::custom_transient_decode_error, ContentEncoding, ContentType, DocType, DocumentRef, + metadata::custom_transient_decode_error, ContentEncoding, ContentType, DocType, DocumentRefs, Section, }; @@ -104,21 +104,21 @@ pub enum SupportedField { /// `id` field. Id(UuidV7) = 1, /// `ref` field. - Ref(DocumentRef) = 2, + Ref(DocumentRefs) = 2, /// `ver` field. Ver(UuidV7) = 3, /// `type` field. Type(DocType) = 4, /// `reply` field. - Reply(DocumentRef) = 5, + Reply(DocumentRefs) = 5, /// `collabs` field. Collabs(Vec) = 7, /// `section` field. Section(Section) = 8, /// `template` field. - Template(DocumentRef) = 9, + Template(DocumentRefs) = 9, /// `parameters` field. - Parameters(DocumentRef) = 10, + Parameters(DocumentRefs) = 10, /// `Content-Encoding` field. ContentEncoding(ContentEncoding) = 11, } @@ -230,17 +230,17 @@ impl minicbor::Decode<'_, crate::decode_context::DecodeContext<'_>> for Supporte d.decode_with(&mut catalyst_types::uuid::CborContext::Tagged) .map(Self::Id) }, - SupportedLabel::Ref => todo!(), + SupportedLabel::Ref => d.decode_with(ctx).map(Self::Ref), SupportedLabel::Ver => { d.decode_with(&mut catalyst_types::uuid::CborContext::Tagged) .map(Self::Ver) }, SupportedLabel::Type => d.decode_with(ctx).map(Self::Type), - SupportedLabel::Reply => todo!(), + SupportedLabel::Reply => d.decode_with(ctx).map(Self::Reply), SupportedLabel::Collabs => todo!(), SupportedLabel::Section => todo!(), - SupportedLabel::Template => todo!(), - SupportedLabel::Parameters => todo!(), + SupportedLabel::Template => d.decode_with(ctx).map(Self::Template), + SupportedLabel::Parameters => d.decode_with(ctx).map(Self::Parameters), SupportedLabel::ContentEncoding => todo!(), }?; diff --git a/rust/signed_doc/src/metadata/utils.rs b/rust/signed_doc/src/metadata/utils.rs index 5614e776cb..f2df23eb81 100644 --- a/rust/signed_doc/src/metadata/utils.rs +++ b/rust/signed_doc/src/metadata/utils.rs @@ -5,6 +5,19 @@ use catalyst_types::{ uuid::{CborContext, UuidV7}, }; use coset::{CborSerializable, Label, ProtectedHeader}; +use minicbor::{Decode, Decoder}; + +/// Decode cose protected header value using minicbor decoder. +pub(crate) fn decode_cose_protected_header_value( + protected: &ProtectedHeader, context: &mut C, label: &str, +) -> Option +where T: for<'a> Decode<'a, C> { + cose_protected_header_find(protected, |key| matches!(key, Label::Text(l) if l == label)) + .and_then(|value| { + let bytes = value.clone().to_vec().unwrap_or_default(); + Decoder::new(&bytes).decode_with(context).ok() + }) +} /// Find a value for a predicate in the protected header. pub(crate) fn cose_protected_header_find( diff --git a/rust/signed_doc/src/providers.rs b/rust/signed_doc/src/providers.rs index 9fd41d1c63..b839c8166e 100644 --- a/rust/signed_doc/src/providers.rs +++ b/rust/signed_doc/src/providers.rs @@ -17,7 +17,7 @@ pub trait VerifyingKeyProvider { /// `CatalystSignedDocument` Provider trait pub trait CatalystSignedDocumentProvider: Send + Sync { - /// Try to get `CatalystSignedDocument` + /// Try to get `CatalystSignedDocument`from document reference fn try_get_doc( &self, doc_ref: &DocumentRef, ) -> impl Future>> + Send; @@ -38,24 +38,34 @@ pub mod tests { use std::{collections::HashMap, time::Duration}; - use catalyst_types::uuid::Uuid; - use super::{ - CatalystId, CatalystSignedDocument, CatalystSignedDocumentProvider, DocumentRef, - VerifyingKey, VerifyingKeyProvider, + CatalystId, CatalystSignedDocument, CatalystSignedDocumentProvider, VerifyingKey, + VerifyingKeyProvider, }; + use crate::{DocLocator, DocumentRef}; /// Simple testing implementation of `CatalystSignedDocumentProvider` - #[derive(Default)] - pub struct TestCatalystSignedDocumentProvider(HashMap); + #[derive(Default, Debug)] + + pub struct TestCatalystSignedDocumentProvider(HashMap); impl TestCatalystSignedDocumentProvider { - /// Inserts document into the `TestCatalystSignedDocumentProvider` + /// Inserts document into the `TestCatalystSignedDocumentProvider` where + /// if document reference is provided use that value. + /// if not use the id and version of the provided doc. /// /// # Errors - /// - Missing document id - pub fn add_document(&mut self, doc: CatalystSignedDocument) -> anyhow::Result<()> { - self.0.insert(doc.doc_id()?.uuid(), doc); + /// Returns error if document reference is not provided and its fail to create one + /// from the given doc. + pub fn add_document( + &mut self, doc_ref: Option, doc: &CatalystSignedDocument, + ) -> anyhow::Result<()> { + if let Some(dr) = doc_ref { + self.0.insert(dr, doc.clone()); + } else { + let dr = DocumentRef::new(doc.doc_id()?, doc.doc_ver()?, DocLocator::default()); + self.0.insert(dr, doc.clone()); + } Ok(()) } } @@ -64,7 +74,7 @@ pub mod tests { async fn try_get_doc( &self, doc_ref: &DocumentRef, ) -> anyhow::Result> { - Ok(self.0.get(&doc_ref.id.uuid()).cloned()) + Ok(self.0.get(doc_ref).cloned()) } fn future_threshold(&self) -> Option { diff --git a/rust/signed_doc/src/validator/mod.rs b/rust/signed_doc/src/validator/mod.rs index 4e1e3895bb..e14429a867 100644 --- a/rust/signed_doc/src/validator/mod.rs +++ b/rust/signed_doc/src/validator/mod.rs @@ -12,14 +12,15 @@ use std::{ use anyhow::Context; use catalyst_types::{catalyst_id::role_index::RoleId, problem_report::ProblemReport}; use rules::{ - ContentEncodingRule, ContentRule, ContentSchema, ContentTypeRule, ParametersRule, RefRule, - ReplyRule, Rules, SectionRule, SignatureKidRule, + ContentEncodingRule, ContentRule, ContentSchema, ContentTypeRule, LinkField, + ParameterLinkRefRule, ParametersRule, RefRule, ReplyRule, Rules, SectionRule, SignatureKidRule, }; use crate::{ doc_types::{ deprecated::{self}, - PROPOSAL, PROPOSAL_COMMENT, PROPOSAL_SUBMISSION_ACTION, + BRAND_PARAMETERS, CAMPAIGN_PARAMETERS, CATEGORY_PARAMETERS, PROPOSAL, PROPOSAL_COMMENT, + PROPOSAL_COMMENT_TEMPLATE, PROPOSAL_SUBMISSION_ACTION, PROPOSAL_TEMPLATE, }, metadata::DocType, providers::{CatalystSignedDocumentProvider, VerifyingKeyProvider}, @@ -42,12 +43,17 @@ where t.try_into().expect("Failed to convert to DocType") } -/// `DOCUMENT_RULES` initialization function -#[allow(clippy::expect_used)] -fn document_rules_init() -> HashMap> { - let mut document_rules_map = HashMap::new(); - - let proposal_document_rules = Rules { +/// Proposal +/// Require field: type, id, ver, template, parameters +/// +fn proposal_rule() -> Rules { + // Parameter can be either brand, campaign or category + let parameters = vec![ + BRAND_PARAMETERS.clone(), + CAMPAIGN_PARAMETERS.clone(), + CATEGORY_PARAMETERS.clone(), + ]; + Rules { content_type: ContentTypeRule { exp: ContentType::Json, }, @@ -56,11 +62,11 @@ fn document_rules_init() -> HashMap> { optional: false, }, content: ContentRule::Templated { - exp_template_type: expect_doc_type(deprecated::PROPOSAL_TEMPLATE_UUID_TYPE), + exp_template_type: PROPOSAL_TEMPLATE.clone(), }, parameters: ParametersRule::Specified { - exp_parameters_type: expect_doc_type(deprecated::CATEGORY_DOCUMENT_UUID_TYPE), - optional: true, + exp_parameters_type: parameters.clone(), + optional: false, }, doc_ref: RefRule::NotSpecified, reply: ReplyRule::NotSpecified, @@ -68,9 +74,23 @@ fn document_rules_init() -> HashMap> { kid: SignatureKidRule { exp: &[RoleId::Proposer], }, - }; + param_link_ref: ParameterLinkRefRule::Specified { + field: LinkField::Template, + }, + } +} - let comment_document_rules = Rules { +/// Proposal Comment +/// Require field: type, id, ver, ref, template, parameters +/// +fn proposal_comment_rule() -> Rules { + // Parameter can be either brand, campaign or category + let parameters = vec![ + BRAND_PARAMETERS.clone(), + CAMPAIGN_PARAMETERS.clone(), + CATEGORY_PARAMETERS.clone(), + ]; + Rules { content_type: ContentTypeRule { exp: ContentType::Json, }, @@ -79,33 +99,54 @@ fn document_rules_init() -> HashMap> { optional: false, }, content: ContentRule::Templated { - exp_template_type: expect_doc_type(deprecated::COMMENT_TEMPLATE_UUID_TYPE), + exp_template_type: PROPOSAL_COMMENT_TEMPLATE.clone(), }, doc_ref: RefRule::Specified { - exp_ref_type: expect_doc_type(deprecated::PROPOSAL_DOCUMENT_UUID_TYPE), + exp_ref_type: PROPOSAL.clone(), optional: false, }, reply: ReplyRule::Specified { - exp_reply_type: expect_doc_type(deprecated::COMMENT_DOCUMENT_UUID_TYPE), + exp_reply_type: PROPOSAL_COMMENT.clone(), optional: true, }, - section: SectionRule::Specified { optional: true }, - parameters: ParametersRule::NotSpecified, + section: SectionRule::NotSpecified, + parameters: ParametersRule::Specified { + exp_parameters_type: parameters.clone(), + optional: false, + }, kid: SignatureKidRule { exp: &[RoleId::Role0], }, - }; + // Link field can be either template or ref + param_link_ref: ParameterLinkRefRule::Specified { + field: LinkField::Template, + }, + } +} + +/// Proposal Submission Action +/// Require fields: type, id, ver, ref, parameters +/// +#[allow(clippy::expect_used)] +fn proposal_submission_action_rule() -> Rules { + // Parameter can be either brand, campaign or category + let parameters = vec![ + BRAND_PARAMETERS.clone(), + CAMPAIGN_PARAMETERS.clone(), + CATEGORY_PARAMETERS.clone(), + ]; let proposal_action_json_schema = jsonschema::options() - .with_draft(jsonschema::Draft::Draft7) - .build( - &serde_json::from_str(include_str!( - "./../../../../specs/signed_docs/docs/payload_schemas/proposal_submission_action.schema.json" - )) - .expect("Must be a valid json file"), - ) - .expect("Must be a valid json scheme file"); - let proposal_submission_action_rules = Rules { + .with_draft(jsonschema::Draft::Draft7) + .build( + &serde_json::from_str(include_str!( + "./../../../../specs/signed_docs/docs/payload_schemas/proposal_submission_action.schema.json" + )) + .expect("Must be a valid json file"), + ) + .expect("Must be a valid json scheme file"); + + Rules { content_type: ContentTypeRule { exp: ContentType::Json, }, @@ -115,11 +156,11 @@ fn document_rules_init() -> HashMap> { }, content: ContentRule::Static(ContentSchema::Json(proposal_action_json_schema)), parameters: ParametersRule::Specified { - exp_parameters_type: expect_doc_type(deprecated::CATEGORY_DOCUMENT_UUID_TYPE), - optional: true, + exp_parameters_type: parameters, + optional: false, }, doc_ref: RefRule::Specified { - exp_ref_type: expect_doc_type(deprecated::PROPOSAL_DOCUMENT_UUID_TYPE), + exp_ref_type: PROPOSAL.clone(), optional: false, }, reply: ReplyRule::NotSpecified, @@ -127,11 +168,19 @@ fn document_rules_init() -> HashMap> { kid: SignatureKidRule { exp: &[RoleId::Proposer], }, - }; + param_link_ref: ParameterLinkRefRule::Specified { + field: LinkField::Ref, + }, + } +} + +/// `DOCUMENT_RULES` initialization function +fn document_rules_init() -> HashMap> { + let mut document_rules_map = HashMap::new(); - let proposal_rules = Arc::new(proposal_document_rules); - let comment_rules = Arc::new(comment_document_rules); - let action_rules = Arc::new(proposal_submission_action_rules); + let proposal_rules = Arc::new(proposal_rule()); + let comment_rules = Arc::new(proposal_comment_rule()); + let action_rules = Arc::new(proposal_submission_action_rule()); document_rules_map.insert(PROPOSAL.clone(), Arc::clone(&proposal_rules)); document_rules_map.insert(PROPOSAL_COMMENT.clone(), Arc::clone(&comment_rules)); diff --git a/rust/signed_doc/src/validator/rules/doc_ref.rs b/rust/signed_doc/src/validator/rules/doc_ref.rs index 977893b061..94f39c3f7a 100644 --- a/rust/signed_doc/src/validator/rules/doc_ref.rs +++ b/rust/signed_doc/src/validator/rules/doc_ref.rs @@ -3,8 +3,8 @@ use catalyst_types::problem_report::ProblemReport; use crate::{ - metadata::DocType, providers::CatalystSignedDocumentProvider, - validator::utils::validate_provided_doc, CatalystSignedDocument, + providers::CatalystSignedDocumentProvider, validator::utils::validate_doc_refs, + CatalystSignedDocument, DocType, }; /// `ref` field validation rule @@ -26,6 +26,7 @@ impl RefRule { &self, doc: &CatalystSignedDocument, provider: &Provider, ) -> anyhow::Result where Provider: CatalystSignedDocumentProvider { + let context: &str = "Ref rule check"; if let Self::Specified { exp_ref_type, optional, @@ -35,11 +36,10 @@ impl RefRule { let ref_validator = |ref_doc: CatalystSignedDocument| { referenced_doc_check(&ref_doc, exp_ref_type, "ref", doc.report()) }; - return validate_provided_doc(&doc_ref, provider, doc.report(), ref_validator) - .await; + return validate_doc_refs(doc_ref, provider, doc.report(), ref_validator).await; } else if !optional { doc.report() - .missing_field("ref", "Document must have a ref field"); + .missing_field("ref", &format!("{context}, document must have ref field")); return Ok(false); } } @@ -48,7 +48,7 @@ impl RefRule { doc.report().unknown_field( "ref", &doc_ref.to_string(), - "Document does not expect to have a ref field", + &format!("{context}, document does not expect to have a ref field"), ); return Ok(false); } @@ -67,11 +67,12 @@ pub(crate) fn referenced_doc_check( report.missing_field("type", "Referenced document must have type field"); return false; }; + if ref_doc_type != exp_ref_type { report.invalid_value( field_name, - ref_doc_type.to_string().as_str(), - exp_ref_type.to_string().as_str(), + &ref_doc_type.to_string(), + &exp_ref_type.to_string(), "Invalid referenced document type", ); return false; @@ -80,11 +81,14 @@ pub(crate) fn referenced_doc_check( } #[cfg(test)] +#[allow(clippy::similar_names, clippy::too_many_lines)] mod tests { use catalyst_types::uuid::{UuidV4, UuidV7}; use super::*; - use crate::{providers::tests::TestCatalystSignedDocumentProvider, Builder}; + use crate::{ + providers::tests::TestCatalystSignedDocumentProvider, Builder, DocLocator, DocumentRef, + }; #[tokio::test] async fn ref_rule_specified_test() { @@ -94,14 +98,17 @@ mod tests { let valid_referenced_doc_id = UuidV7::new(); let valid_referenced_doc_ver = UuidV7::new(); + let different_id_and_ver_referenced_doc_id = UuidV7::new(); + let different_id_and_ver_referenced_doc_ver = UuidV7::new(); let another_type_referenced_doc_id = UuidV7::new(); let another_type_referenced_doc_ver = UuidV7::new(); let missing_type_referenced_doc_id = UuidV7::new(); let missing_type_referenced_doc_ver = UuidV7::new(); - // prepare replied documents + // Prepare provider documents { - let ref_doc = Builder::new() + // Valid one + let doc = Builder::new() .with_json_metadata(serde_json::json!({ "id": valid_referenced_doc_id.to_string(), "ver": valid_referenced_doc_ver.to_string(), @@ -109,10 +116,22 @@ mod tests { })) .unwrap() .build(); - provider.add_document(ref_doc).unwrap(); + provider.add_document(None, &doc).unwrap(); + + // Having different id and ver in registered reference + let doc_ref = DocumentRef::new(UuidV7::new(), UuidV7::new(), DocLocator::default()); + let doc = Builder::new() + .with_json_metadata(serde_json::json!({ + "id": different_id_and_ver_referenced_doc_id.to_string(), + "ver": different_id_and_ver_referenced_doc_ver.to_string(), + "type": exp_ref_type.to_string(), + })) + .unwrap() + .build(); + provider.add_document(Some(doc_ref), &doc).unwrap(); - // reply doc with other `type` field - let ref_doc = Builder::new() + // Having another `type` field + let doc = Builder::new() .with_json_metadata(serde_json::json!({ "id": another_type_referenced_doc_id.to_string(), "ver": another_type_referenced_doc_ver.to_string(), @@ -120,33 +139,59 @@ mod tests { })) .unwrap() .build(); - provider.add_document(ref_doc).unwrap(); + provider.add_document(None, &doc).unwrap(); - // missing `type` field in the referenced document - let ref_doc = Builder::new() + // Missing `type` field in the referenced document + let doc = Builder::new() .with_json_metadata(serde_json::json!({ "id": missing_type_referenced_doc_id.to_string(), "ver": missing_type_referenced_doc_ver.to_string(), })) .unwrap() .build(); - provider.add_document(ref_doc).unwrap(); + provider.add_document(None, &doc).unwrap(); } - // all correct + // Create a document where `ref` field is required and referencing a valid document in + // provider. Using doc ref of new implementation. let rule = RefRule::Specified { exp_ref_type: exp_ref_type.into(), optional: false, }; let doc = Builder::new() .with_json_metadata(serde_json::json!({ - "ref": {"id": valid_referenced_doc_id.to_string(), "ver": valid_referenced_doc_ver.to_string() } - })) + "ref": [{"id": valid_referenced_doc_id.to_string(), "ver":valid_referenced_doc_ver.to_string(), "cid": "0x" }]})) + .unwrap() + .build(); + assert!(rule.check(&doc, &provider).await.unwrap()); + + // Checking backward compatible + let doc = Builder::new() + .with_json_metadata(serde_json::json!({ + "ref": {"id": valid_referenced_doc_id.to_string(), "ver":valid_referenced_doc_ver.to_string()}})) .unwrap() .build(); assert!(rule.check(&doc, &provider).await.unwrap()); - // all correct, `ref` field is missing, but its optional + // Having multiple refs, where one ref doc is not found. + // Checking match all of + let doc = Builder::new() + .with_json_metadata(serde_json::json!({ + "ref": [{"id": valid_referenced_doc_id.to_string(), "ver":valid_referenced_doc_ver.to_string(), "cid": "0x" }, + {"id": different_id_and_ver_referenced_doc_id.to_string(), "ver":different_id_and_ver_referenced_doc_ver.to_string(), "cid": "0x" }]})) + .unwrap() + .build(); + assert!(!rule.check(&doc, &provider).await.unwrap()); + + // Invalid the ref doc id and ver doesn't match the id and ver in ref doc ref + let doc = Builder::new() + .with_json_metadata(serde_json::json!({ + "ref": [{"id": different_id_and_ver_referenced_doc_id.to_string(), "ver":different_id_and_ver_referenced_doc_ver.to_string(), "cid": "0x" }]})) + .unwrap() + .build(); + assert!(!rule.check(&doc, &provider).await.unwrap()); + + // All correct, `ref` field is missing, but its optional let rule = RefRule::Specified { exp_ref_type: exp_ref_type.into(), optional: true, @@ -154,7 +199,7 @@ mod tests { let doc = Builder::new().build(); assert!(rule.check(&doc, &provider).await.unwrap()); - // missing `ref` field, but its required + // Missing `ref` field, but its required let rule = RefRule::Specified { exp_ref_type: exp_ref_type.into(), optional: false, @@ -162,20 +207,20 @@ mod tests { let doc = Builder::new().build(); assert!(!rule.check(&doc, &provider).await.unwrap()); - // reference to the document with another `type` field + // Reference to the document with another `type` field let doc = Builder::new() .with_json_metadata(serde_json::json!({ - "ref": {"id": another_type_referenced_doc_id.to_string(), "ver": another_type_referenced_doc_ver.to_string() } - })) + "ref": {"id": another_type_referenced_doc_id.to_string(), "ver": +another_type_referenced_doc_ver.to_string() } })) .unwrap() .build(); assert!(!rule.check(&doc, &provider).await.unwrap()); - // missing `type` field in the referenced document + // Missing `type` field in the referenced document let doc = Builder::new() .with_json_metadata(serde_json::json!({ - "ref": {"id": missing_type_referenced_doc_id.to_string(), "ver": missing_type_referenced_doc_ver.to_string() } - })) + "ref": {"id": missing_type_referenced_doc_id.to_string(), "ver": +missing_type_referenced_doc_ver.to_string() } })) .unwrap() .build(); assert!(!rule.check(&doc, &provider).await.unwrap()); @@ -183,8 +228,8 @@ mod tests { // cannot find a referenced document let doc = Builder::new() .with_json_metadata(serde_json::json!({ - "ref": {"id": UuidV7::new().to_string(), "ver": UuidV7::new().to_string() } - })) + "ref": {"id": UuidV7::new().to_string(), "ver": +UuidV7::new().to_string() } })) .unwrap() .build(); assert!(!rule.check(&doc, &provider).await.unwrap()); @@ -201,7 +246,8 @@ mod tests { let ref_id = UuidV7::new(); let ref_ver = UuidV7::new(); let doc = Builder::new() - .with_json_metadata(serde_json::json!({"ref": {"id": ref_id.to_string(), "ver": ref_ver.to_string() } })) + .with_json_metadata(serde_json::json!({"ref": {"id": ref_id.to_string(), +"ver": ref_ver.to_string() } })) .unwrap() .build(); assert!(!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 165dcb043a..47fafa5cb7 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::{providers::CatalystSignedDocumentProvider, CatalystSignedDocument}; mod content_encoding; mod content_type; mod doc_ref; +mod param_link_ref; mod parameters; mod reply; mod section; @@ -17,6 +18,7 @@ mod template; pub(crate) use content_encoding::ContentEncodingRule; pub(crate) use content_type::ContentTypeRule; pub(crate) use doc_ref::RefRule; +pub(crate) use param_link_ref::{LinkField, ParameterLinkRefRule}; pub(crate) use parameters::ParametersRule; pub(crate) use reply::ReplyRule; pub(crate) use section::SectionRule; @@ -41,6 +43,8 @@ pub(crate) struct Rules { pub(crate) parameters: ParametersRule, /// `kid` field validation rule pub(crate) kid: SignatureKidRule, + /// Link reference rule + pub(crate) param_link_ref: ParameterLinkRefRule, } impl Rules { @@ -52,12 +56,13 @@ impl Rules { let rules = [ self.content_type.check(doc).boxed(), self.content_encoding.check(doc).boxed(), - self.doc_ref.check(doc, provider).boxed(), self.content.check(doc, provider).boxed(), + self.doc_ref.check(doc, provider).boxed(), self.reply.check(doc, provider).boxed(), self.section.check(doc).boxed(), self.parameters.check(doc, provider).boxed(), self.kid.check(doc).boxed(), + self.param_link_ref.check(doc, provider).boxed(), ]; let res = futures::future::join_all(rules) diff --git a/rust/signed_doc/src/validator/rules/param_link_ref.rs b/rust/signed_doc/src/validator/rules/param_link_ref.rs new file mode 100644 index 0000000000..dbbede9df8 --- /dev/null +++ b/rust/signed_doc/src/validator/rules/param_link_ref.rs @@ -0,0 +1,167 @@ +//! Parameter linked reference rule impl. + +use crate::{ + providers::CatalystSignedDocumentProvider, validator::utils::validate_doc_refs, + CatalystSignedDocument, +}; + +/// Filed that is being used for linked ref +pub(crate) enum LinkField { + /// Ref field + Ref, + /// Template field + Template, +} + +/// Parameter Link reference validation rule +pub(crate) enum ParameterLinkRefRule { + /// Link ref specified + Specified { + /// Filed that is being used for linked ref + field: LinkField, + }, + /// Link ref is not specified + #[allow(dead_code)] + NotSpecified, +} + +impl ParameterLinkRefRule { + /// Validation rule + pub(crate) async fn check( + &self, doc: &CatalystSignedDocument, provider: &Provider, + ) -> anyhow::Result + where Provider: CatalystSignedDocumentProvider { + if let Self::Specified { field } = self { + let param_link_ref_validator = |ref_doc: CatalystSignedDocument| { + // The parameters MUST be the same + doc.doc_meta().parameters() == ref_doc.doc_meta().parameters() + }; + + // Which field is use for linked reference + let param_link_ref = match field { + LinkField::Ref => doc.doc_meta().doc_ref(), + LinkField::Template => doc.doc_meta().template(), + }; + + let Some(param_link_ref) = param_link_ref else { + doc.report() + .missing_field("Link ref", "Invalid link reference"); + return Ok(false); + }; + + return validate_doc_refs( + param_link_ref, + provider, + doc.report(), + param_link_ref_validator, + ) + .await; + } + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use catalyst_types::uuid::{UuidV4, UuidV7}; + + use crate::{ + providers::tests::TestCatalystSignedDocumentProvider, + validator::rules::param_link_ref::{LinkField, ParameterLinkRefRule}, + Builder, + }; + #[tokio::test] + async fn param_link_ref_specified_test() { + let mut provider = TestCatalystSignedDocumentProvider::default(); + + let doc1_id = UuidV7::new(); + let doc1_ver = UuidV7::new(); + let doc2_id = UuidV7::new(); + let doc2_ver = UuidV7::new(); + + let doc_type = UuidV4::new(); + + let category_id = UuidV7::new(); + let category_ver = UuidV7::new(); + let category_type = UuidV4::new(); + + let campaign_id = UuidV7::new(); + let campaign_ver = UuidV7::new(); + let campaign_type = UuidV4::new(); + + // Prepare provider documents + { + // Doc being referenced - parameter MUST match + let doc = Builder::new() + .with_json_metadata(serde_json::json!({ + "id": doc1_id.to_string(), + "ver": doc1_ver.to_string(), + "type": doc_type.to_string(), + "parameters": [{"id": category_id.to_string(), "ver": category_ver.to_string(), "cid": "0x" }, {"id": campaign_id.to_string(), "ver": campaign_ver.to_string(), "cid": "0x" }] + })) + .unwrap() + .build(); + provider.add_document(None, &doc).unwrap(); + + // Doc being referenced - parameter does not match + let doc = Builder::new() + .with_json_metadata(serde_json::json!({ + "id": doc2_id.to_string(), + "ver": doc2_ver.to_string(), + "type": doc_type.to_string(), + "parameters": [{"id": campaign_id.to_string(), "ver": campaign_ver.to_string(), "cid": "0x" }] + })) + .unwrap() + .build(); + provider.add_document(None, &doc).unwrap(); + + // Category doc + let doc = Builder::new() + .with_json_metadata(serde_json::json!({ + "id": category_id.to_string(), + "ver": category_ver.to_string(), + "type": category_type.to_string(), + })) + .unwrap() + .build(); + provider.add_document(None, &doc).unwrap(); + + // Campaign doc + let doc = Builder::new() + .with_json_metadata(serde_json::json!({ + "id": campaign_id.to_string(), + "ver": campaign_ver.to_string(), + "type": campaign_type.to_string(), + })) + .unwrap() + .build(); + provider.add_document(None, &doc).unwrap(); + } + + // Use Ref as a linked reference + let rule = ParameterLinkRefRule::Specified { + field: LinkField::Ref, + }; + // Parameter must match + let doc = Builder::new() + .with_json_metadata(serde_json::json!({ + "ref": [{"id": doc1_id.to_string(), "ver": doc1_ver.to_string(), "cid": "0x" }], + "parameters": + [{"id": category_id.to_string(), "ver": category_ver.to_string(), "cid": "0x" }, {"id": campaign_id.to_string(), "ver": campaign_ver.to_string(), "cid": "0x" }] + })) + .unwrap() + .build(); + assert!(rule.check(&doc, &provider).await.unwrap()); + + // Parameter does not match + let doc = Builder::new() + .with_json_metadata(serde_json::json!({ + "ref": {"id": doc2_id.to_string(), "ver": doc2_ver.to_string()}, + "parameters": + [{"id": category_id.to_string(), "ver": category_ver.to_string(), "cid": "0x" }, {"id": campaign_id.to_string(), "ver": campaign_ver.to_string(), "cid": "0x" }] + })) + .unwrap() + .build(); + assert!(!rule.check(&doc, &provider).await.unwrap()); + } +} diff --git a/rust/signed_doc/src/validator/rules/parameters.rs b/rust/signed_doc/src/validator/rules/parameters.rs index 41ad404df0..c7d40b15e5 100644 --- a/rust/signed_doc/src/validator/rules/parameters.rs +++ b/rust/signed_doc/src/validator/rules/parameters.rs @@ -2,8 +2,8 @@ use super::doc_ref::referenced_doc_check; use crate::{ - metadata::DocType, providers::CatalystSignedDocumentProvider, - validator::utils::validate_provided_doc, CatalystSignedDocument, + providers::CatalystSignedDocumentProvider, validator::utils::validate_doc_refs, + CatalystSignedDocument, DocType, }; /// `parameters` field validation rule @@ -12,11 +12,12 @@ pub(crate) enum ParametersRule { /// Is `parameters` specified Specified { /// expected `type` field of the parameter doc - exp_parameters_type: DocType, + exp_parameters_type: Vec, /// optional flag for the `parameters` field optional: bool, }, /// `parameters` is not specified + #[allow(unused)] NotSpecified, } @@ -26,30 +27,31 @@ impl ParametersRule { &self, doc: &CatalystSignedDocument, provider: &Provider, ) -> anyhow::Result where Provider: CatalystSignedDocumentProvider { + let context: &str = "Parameter rule check"; if let Self::Specified { exp_parameters_type, optional, } = self { - if let Some(parameters) = doc.doc_meta().parameters() { - let parameters_validator = |replied_doc: CatalystSignedDocument| { - referenced_doc_check( - &replied_doc, - exp_parameters_type, - "parameters", - doc.report(), - ) + if let Some(parameters_ref) = doc.doc_meta().parameters() { + let parameters_validator = |ref_doc: CatalystSignedDocument| { + // Check that the type matches one of the expected ones + exp_parameters_type.iter().any(|exp_type| { + referenced_doc_check(&ref_doc, exp_type, "parameters", doc.report()) + }) }; - return validate_provided_doc( - ¶meters, + return validate_doc_refs( + parameters_ref, provider, doc.report(), parameters_validator, ) .await; } else if !optional { - doc.report() - .missing_field("parameters", "Document must have a parameters field"); + doc.report().missing_field( + "parameters", + &format!("{context}, document must have parameters field"), + ); return Ok(false); } } @@ -58,7 +60,7 @@ impl ParametersRule { doc.report().unknown_field( "parameters", ¶meters.to_string(), - "Document does not expect to have a parameters field", + &format!("{context}, document does not expect to have a parameters field"), ); return Ok(false); } @@ -69,6 +71,7 @@ impl ParametersRule { } #[cfg(test)] +#[allow(clippy::similar_names, clippy::too_many_lines)] mod tests { use catalyst_types::uuid::{UuidV4, UuidV7}; @@ -79,29 +82,51 @@ mod tests { async fn ref_rule_specified_test() { let mut provider = TestCatalystSignedDocumentProvider::default(); - let exp_parameters_type = UuidV4::new(); + let exp_parameters_cat_type = UuidV4::new(); + let exp_parameters_cam_type = UuidV4::new(); + let exp_parameters_brand_type = UuidV4::new(); + + let exp_param_type: Vec = vec![ + exp_parameters_cat_type.into(), + exp_parameters_cam_type.into(), + exp_parameters_brand_type.into(), + ]; let valid_category_doc_id = UuidV7::new(); let valid_category_doc_ver = UuidV7::new(); + let valid_brand_doc_id = UuidV7::new(); + let valid_brand_doc_ver = UuidV7::new(); let another_type_category_doc_id = UuidV7::new(); let another_type_category_doc_ver = UuidV7::new(); let missing_type_category_doc_id = UuidV7::new(); let missing_type_category_doc_ver = UuidV7::new(); - // prepare replied documents + // Prepare provider documents { - let ref_doc = Builder::new() + // Category doc + let doc = Builder::new() .with_json_metadata(serde_json::json!({ "id": valid_category_doc_id.to_string(), "ver": valid_category_doc_ver.to_string(), - "type": exp_parameters_type.to_string() + "type": exp_parameters_cat_type.to_string(), })) .unwrap() .build(); - provider.add_document(ref_doc).unwrap(); + provider.add_document(None, &doc).unwrap(); - // reply doc with other `type` field - let ref_doc = Builder::new() + // Brand doc + let doc = Builder::new() + .with_json_metadata(serde_json::json!({ + "id": valid_brand_doc_id.to_string(), + "ver": valid_brand_doc_ver.to_string(), + "type": exp_parameters_cat_type.to_string(), + })) + .unwrap() + .build(); + provider.add_document(None, &doc).unwrap(); + + // Other type + let doc = Builder::new() .with_json_metadata(serde_json::json!({ "id": another_type_category_doc_id.to_string(), "ver": another_type_category_doc_ver.to_string(), @@ -109,49 +134,83 @@ mod tests { })) .unwrap() .build(); - provider.add_document(ref_doc).unwrap(); + provider.add_document(None, &doc).unwrap(); - // missing `type` field in the referenced document - let ref_doc = Builder::new() + // Missing `type` field in the referenced document + let doc = Builder::new() .with_json_metadata(serde_json::json!({ "id": missing_type_category_doc_id.to_string(), "ver": missing_type_category_doc_ver.to_string(), })) .unwrap() .build(); - provider.add_document(ref_doc).unwrap(); + provider.add_document(None, &doc).unwrap(); } - // all correct + // Create a document where `parameters` field is required and referencing a valid document + // in provider. Using doc ref of new implementation. let rule = ParametersRule::Specified { - exp_parameters_type: exp_parameters_type.into(), + exp_parameters_type: exp_param_type.clone(), optional: false, }; let doc = Builder::new() .with_json_metadata(serde_json::json!({ - "parameters": {"id": valid_category_doc_id.to_string(), "ver": valid_category_doc_ver } + "parameters": + [{"id": valid_category_doc_id.to_string(), "ver": valid_category_doc_ver.to_string(), "cid": "0x" }] + })) + .unwrap() + .build(); + assert!(rule.check(&doc, &provider).await.unwrap()); + + // Parameters contain multiple ref + let doc = Builder::new() + .with_json_metadata(serde_json::json!({ + "parameters": + [{"id": valid_category_doc_id.to_string(), "ver": valid_category_doc_ver.to_string(), "cid": "0x" }, + {"id": valid_brand_doc_id.to_string(), "ver": valid_brand_doc_ver.to_string(), "cid": "0x" }] + })) + .unwrap() + .build(); + assert!(rule.check(&doc, &provider).await.unwrap()); + + // Parameters contain multiple ref, but one of them is invalid (not registered). + let doc = Builder::new() + .with_json_metadata(serde_json::json!({ + "parameters": + [{"id": valid_category_doc_id.to_string(), "ver": valid_category_doc_ver.to_string(), "cid": "0x" }, + {"id": UuidV7::new().to_string() , "ver": UuidV7::new().to_string(), "cid": "0x" }] + })) + .unwrap() + .build(); + assert!(!rule.check(&doc, &provider).await.unwrap()); + + // Checking backward compatible + let doc = Builder::new() + .with_json_metadata(serde_json::json!({ + "parameters": + {"id": valid_category_doc_id.to_string(), "ver": valid_category_doc_ver.to_string()} })) .unwrap() .build(); assert!(rule.check(&doc, &provider).await.unwrap()); - // all correct, `parameters` field is missing, but its optional + // All correct, `parameters` field is missing, but its optional let rule = ParametersRule::Specified { - exp_parameters_type: exp_parameters_type.into(), + exp_parameters_type: exp_param_type.clone(), optional: true, }; let doc = Builder::new().build(); assert!(rule.check(&doc, &provider).await.unwrap()); - // missing `parameters` field, but its required + // Missing `parameters` field, but its required let rule = ParametersRule::Specified { - exp_parameters_type: exp_parameters_type.into(), + exp_parameters_type: exp_param_type, optional: false, }; let doc = Builder::new().build(); assert!(!rule.check(&doc, &provider).await.unwrap()); - // reference to the document with another `type` field + // Reference to the document with another `type` field let doc = Builder::new() .with_json_metadata(serde_json::json!({ "parameters": {"id": another_type_category_doc_id.to_string(), "ver": another_type_category_doc_ver.to_string() } @@ -160,7 +219,7 @@ mod tests { .build(); assert!(!rule.check(&doc, &provider).await.unwrap()); - // missing `type` field in the referenced document + // Missing `type` field in the referenced document let doc = Builder::new() .with_json_metadata(serde_json::json!({ "parameters": {"id": missing_type_category_doc_id.to_string(), "ver": missing_type_category_doc_ver.to_string() } @@ -169,7 +228,7 @@ mod tests { .build(); assert!(!rule.check(&doc, &provider).await.unwrap()); - // cannot find a referenced document + // Cannot find a referenced document let doc = Builder::new() .with_json_metadata(serde_json::json!({ "parameters": {"id": UuidV7::new().to_string(), "ver": UuidV7::new().to_string() } diff --git a/rust/signed_doc/src/validator/rules/reply.rs b/rust/signed_doc/src/validator/rules/reply.rs index 09b48ea28d..6dccd07efd 100644 --- a/rust/signed_doc/src/validator/rules/reply.rs +++ b/rust/signed_doc/src/validator/rules/reply.rs @@ -2,8 +2,8 @@ use super::doc_ref::referenced_doc_check; use crate::{ - metadata::DocType, providers::CatalystSignedDocumentProvider, - validator::utils::validate_provided_doc, CatalystSignedDocument, + providers::CatalystSignedDocumentProvider, validator::utils::validate_doc_refs, + CatalystSignedDocument, DocType, }; /// `reply` field validation rule @@ -26,45 +26,39 @@ impl ReplyRule { &self, doc: &CatalystSignedDocument, provider: &Provider, ) -> anyhow::Result where Provider: CatalystSignedDocumentProvider { + let context: &str = "Reply rule check"; if let Self::Specified { exp_reply_type, optional, } = self { - if let Some(reply) = doc.doc_meta().reply() { - let reply_validator = |replied_doc: CatalystSignedDocument| { - if !referenced_doc_check(&replied_doc, exp_reply_type, "reply", doc.report()) { + if let Some(reply_ref) = doc.doc_meta().reply() { + let reply_validator = |ref_doc: CatalystSignedDocument| { + // Validate type + if !referenced_doc_check(&ref_doc, exp_reply_type, "reply", doc.report()) { return false; } - let Some(doc_ref) = doc.doc_meta().doc_ref() else { - doc.report() - .missing_field("ref", "Document must have a ref field"); - return false; - }; - let Some(replied_doc_ref) = replied_doc.doc_meta().doc_ref() else { - doc.report() - .missing_field("ref", "Referenced document must have ref field"); + // Get `ref` from both the doc and the ref doc + let Some(ref_doc_dr) = ref_doc.doc_meta().doc_ref() else { + doc.report().missing_field("Ref doc `ref` field", context); return false; }; - if replied_doc_ref.id != doc_ref.id { - doc.report().invalid_value( - "reply", - doc_ref.id .to_string().as_str(), - replied_doc_ref.id.to_string().as_str(), - "Invalid referenced document. Document ID should aligned with the replied document.", - ); + let Some(doc_dr) = doc.doc_meta().doc_ref() else { + doc.report().missing_field("Doc `ref` field", context); return false; - } + }; - true + // Checking the ref field of ref doc, it should match the ref field of the doc + ref_doc_dr == doc_dr }; - return validate_provided_doc(&reply, provider, doc.report(), reply_validator) - .await; + return validate_doc_refs(reply_ref, provider, doc.report(), reply_validator).await; } else if !optional { - doc.report() - .missing_field("reply", "Document must have a reply field"); + doc.report().missing_field( + "reply", + &format!("{context}, document must have reply field"), + ); return Ok(false); } } @@ -73,7 +67,7 @@ impl ReplyRule { doc.report().unknown_field( "reply", &reply.to_string(), - "Document does not expect to have a reply field", + &format!("{context}, document does not expect to have a reply field"), ); return Ok(false); } @@ -99,53 +93,45 @@ mod tests { let common_ref_id = UuidV7::new(); let common_ref_ver = UuidV7::new(); + let doc1_id = UuidV7::new(); + let doc1_ver = UuidV7::new(); + let doc2_id = UuidV7::new(); + let doc2_ver = UuidV7::new(); + let valid_replied_doc_id = UuidV7::new(); let valid_replied_doc_ver = UuidV7::new(); let another_type_replied_doc_ver = UuidV7::new(); let another_type_replied_doc_id = UuidV7::new(); - let missing_ref_replied_doc_ver = UuidV7::new(); let missing_ref_replied_doc_id = UuidV7::new(); let missing_type_replied_doc_ver = UuidV7::new(); let missing_type_replied_doc_id = UuidV7::new(); - // prepare replied documents + // Prepare provider documents { - let ref_doc = Builder::new() + let doc = Builder::new() .with_json_metadata(serde_json::json!({ - "ref": { "id": common_ref_id.to_string(), "ver": common_ref_ver.to_string() }, - "id": valid_replied_doc_id.to_string(), - "ver": valid_replied_doc_ver.to_string(), - "type": exp_reply_type.to_string() + "id": doc1_id.to_string(), + "ver": doc1_ver.to_string(), + "type": exp_reply_type.to_string(), + "ref": { "id": doc2_id.to_string(), "ver": doc2_ver.to_string(), "cid": "0x" } })) .unwrap() .build(); - provider.add_document(ref_doc).unwrap(); + provider.add_document(None, &doc).unwrap(); - // reply doc with other `type` field - let ref_doc = Builder::new() + // Reply doc with other `type` field + let doc = Builder::new() .with_json_metadata(serde_json::json!({ - "ref": { "id": common_ref_id.to_string(), "ver": common_ref_ver.to_string() }, "id": another_type_replied_doc_id.to_string(), "ver": another_type_replied_doc_ver.to_string(), "type": UuidV4::new().to_string() })) .unwrap() .build(); - provider.add_document(ref_doc).unwrap(); + provider.add_document(None, &doc).unwrap(); - // missing `ref` field in the referenced document - let ref_doc = Builder::new() - .with_json_metadata(serde_json::json!({ - "id": missing_ref_replied_doc_id.to_string(), - "ver": missing_ref_replied_doc_ver.to_string(), - "type": exp_reply_type.to_string() - })) - .unwrap() - .build(); - provider.add_document(ref_doc).unwrap(); - - // missing `type` field in the referenced document - let ref_doc = Builder::new() + // Missing `type` field in the referenced document + let doc = Builder::new() .with_json_metadata(serde_json::json!({ "ref": { "id": common_ref_id.to_string(), "ver": common_ref_ver.to_string() }, "id": missing_type_replied_doc_id.to_string(), @@ -153,18 +139,21 @@ mod tests { })) .unwrap() .build(); - provider.add_document(ref_doc).unwrap(); + provider.add_document(None, &doc).unwrap(); } - // all correct + // Create a document where `reply` field is required and referencing a valid document in + // provider. let rule = ReplyRule::Specified { exp_reply_type: exp_reply_type.into(), optional: false, }; + + // Doc1 ref reply to doc2. Doc1 ref filed should match doc2 ref field let doc = Builder::new() .with_json_metadata(serde_json::json!({ - "ref": { "id": common_ref_id.to_string(), "ver": common_ref_ver.to_string() }, - "reply": { "id": valid_replied_doc_id.to_string(), "ver": valid_replied_doc_ver.to_string() } + "ref": { "id": doc2_id.to_string(), "ver": doc2_ver.to_string() }, + "reply": { "id": doc1_id.to_string(), "ver": doc1_ver.to_string() } })) .unwrap() .build(); diff --git a/rust/signed_doc/src/validator/rules/section.rs b/rust/signed_doc/src/validator/rules/section.rs index 8720353425..b31b7fe0fb 100644 --- a/rust/signed_doc/src/validator/rules/section.rs +++ b/rust/signed_doc/src/validator/rules/section.rs @@ -5,6 +5,7 @@ use crate::CatalystSignedDocument; /// `section` field validation rule pub(crate) enum SectionRule { /// Is 'section' specified + #[allow(dead_code)] Specified { /// optional flag for the `section` field optional: bool, diff --git a/rust/signed_doc/src/validator/rules/template.rs b/rust/signed_doc/src/validator/rules/template.rs index a320ccbfcd..849487bd6d 100644 --- a/rust/signed_doc/src/validator/rules/template.rs +++ b/rust/signed_doc/src/validator/rules/template.rs @@ -4,10 +4,8 @@ use std::fmt::Write; use super::doc_ref::referenced_doc_check; use crate::{ - metadata::{ContentType, DocType}, - providers::CatalystSignedDocumentProvider, - validator::utils::validate_provided_doc, - CatalystSignedDocument, + metadata::ContentType, providers::CatalystSignedDocumentProvider, + validator::utils::validate_doc_refs, CatalystSignedDocument, DocType, }; /// Enum represents different content schemas, against which documents content would be @@ -21,6 +19,7 @@ pub(crate) enum ContentSchema { /// Document's content validation rule pub(crate) enum ContentRule { /// Based on the 'template' field and loaded corresponding template document + #[allow(dead_code)] Templated { /// expected `type` field of the template exp_template_type: DocType, @@ -36,27 +35,27 @@ pub(crate) enum ContentRule { impl ContentRule { /// Field validation rule + #[allow(dead_code)] pub(crate) async fn check( &self, doc: &CatalystSignedDocument, provider: &Provider, ) -> anyhow::Result where Provider: CatalystSignedDocumentProvider { + let context = "Content/Template rule check"; if let Self::Templated { exp_template_type } = self { let Some(template_ref) = doc.doc_meta().template() else { doc.report() - .missing_field("template", "Document must have a template field"); + .missing_field("template", &format!("{context}, doc")); return Ok(false); }; - let template_validator = |template_doc: CatalystSignedDocument| { if !referenced_doc_check(&template_doc, exp_template_type, "template", doc.report()) { return false; } - let Ok(template_content_type) = template_doc.doc_content_type() else { doc.report().missing_field( "content-type", - "Referenced template document must have a content-type field", + &format!("{context}, referenced document must have a content-type field"), ); return false; }; @@ -68,20 +67,15 @@ impl ContentRule { }, } }; - return validate_provided_doc( - &template_ref, - provider, - doc.report(), - template_validator, - ) - .await; + return validate_doc_refs(template_ref, provider, doc.report(), template_validator) + .await; } if let Self::Static(content_schema) = self { if let Some(template) = doc.doc_meta().template() { doc.report().unknown_field( "template", &template.to_string(), - "Document does not expect to have a template field", + &format!("{context} Static, Document does not expect to have a template field",) ); return Ok(false); } @@ -93,7 +87,7 @@ impl ContentRule { doc.report().unknown_field( "template", &template.to_string(), - "Document does not expect to have a template field", + &format!("{context} Not Specified, Document does not expect to have a template field",) ); return Ok(false); } @@ -135,7 +129,7 @@ fn templated_json_schema_check( content_schema_check(doc, &ContentSchema::Json(schema_validator)) } - +#[allow(dead_code)] /// Validating the document's content against the provided schema fn content_schema_check(doc: &CatalystSignedDocument, schema: &ContentSchema) -> bool { let Ok(doc_content) = doc.decoded_content() else { @@ -206,9 +200,9 @@ mod tests { let missing_content_template_doc_id = UuidV7::new(); let invalid_content_template_doc_id = UuidV7::new(); - // prepare replied documents + // Prepare provider documents { - let ref_doc = Builder::new() + let doc = Builder::new() .with_json_metadata(serde_json::json!({ "id": valid_template_doc_id.to_string(), "ver": valid_template_doc_id.to_string(), @@ -219,7 +213,7 @@ mod tests { .with_decoded_content(json_schema.clone()) .unwrap() .build(); - provider.add_document(ref_doc).unwrap(); + provider.add_document(None, &doc).unwrap(); // reply doc with other `type` field let ref_doc = Builder::new() @@ -233,7 +227,7 @@ mod tests { .with_decoded_content(json_schema.clone()) .unwrap() .build(); - provider.add_document(ref_doc).unwrap(); + provider.add_document(None, &ref_doc).unwrap(); // missing `type` field in the referenced document let ref_doc = Builder::new() @@ -246,7 +240,7 @@ mod tests { .with_decoded_content(json_schema.clone()) .unwrap() .build(); - provider.add_document(ref_doc).unwrap(); + provider.add_document(None, &ref_doc).unwrap(); // missing `content-type` field in the referenced document let ref_doc = Builder::new() @@ -259,7 +253,7 @@ mod tests { .with_decoded_content(json_schema.clone()) .unwrap() .build(); - provider.add_document(ref_doc).unwrap(); + provider.add_document(None, &ref_doc).unwrap(); // missing content let ref_doc = Builder::new() @@ -271,7 +265,7 @@ mod tests { })) .unwrap() .build(); - provider.add_document(ref_doc).unwrap(); + provider.add_document(None, &ref_doc).unwrap(); // invalid content, must be json encoded let ref_doc = Builder::new() @@ -285,16 +279,28 @@ mod tests { .with_decoded_content(vec![]) .unwrap() .build(); - provider.add_document(ref_doc).unwrap(); + provider.add_document(None, &ref_doc).unwrap(); } - // all correct + // Create a document where `templates` field is required and referencing a valid document + // in provider. Using doc ref of new implementation. let rule = ContentRule::Templated { exp_template_type: exp_template_type.into(), }; let doc = Builder::new() .with_json_metadata(serde_json::json!({ - "template": {"id": valid_template_doc_id.to_string(), "ver": valid_template_doc_id.to_string() } + "template": [{"id": valid_template_doc_id.to_string(), "ver": valid_template_doc_id.to_string(), "cid": "0x" }] + })) + .unwrap() + .with_decoded_content(json_content.clone()) + .unwrap() + .build(); + assert!(rule.check(&doc, &provider).await.unwrap()); + + // Checking backward compatible + let doc = Builder::new() + .with_json_metadata(serde_json::json!({ + "template": {"id": valid_template_doc_id.to_string(), "ver": valid_template_doc_id.to_string()} })) .unwrap() .with_decoded_content(json_content.clone()).unwrap() diff --git a/rust/signed_doc/src/validator/utils.rs b/rust/signed_doc/src/validator/utils.rs index 4b25a9bbfa..1dc2a06cb3 100644 --- a/rust/signed_doc/src/validator/utils.rs +++ b/rust/signed_doc/src/validator/utils.rs @@ -2,7 +2,9 @@ use catalyst_types::problem_report::ProblemReport; -use crate::{providers::CatalystSignedDocumentProvider, CatalystSignedDocument, DocumentRef}; +use crate::{ + providers::CatalystSignedDocumentProvider, CatalystSignedDocument, DocumentRef, DocumentRefs, +}; /// A helper validation document function, which validates a document from the /// `ValidationDataProvider`. @@ -13,13 +15,56 @@ where Provider: CatalystSignedDocumentProvider, Validator: Fn(CatalystSignedDocument) -> bool, { + const CONTEXT: &str = "Validation data provider"; + + // General check for document ref + + // Getting the Signed Document instance from a doc ref. + // The reference document must exist if let Some(doc) = provider.try_get_doc(doc_ref).await? { + let id = doc + .doc_id() + .inspect_err(|_| report.missing_field("id", CONTEXT))?; + + let ver = doc + .doc_ver() + .inspect_err(|_| report.missing_field("ver", CONTEXT))?; + // id and version must match the values in ref doc + if &id != doc_ref.id() && &ver != doc_ref.ver() { + report.invalid_value( + "id and version", + &format!("id: {id}, ver: {ver}"), + &format!("id: {}, ver: {}", doc_ref.id(), doc_ref.ver()), + CONTEXT, + ); + return Ok(false); + } Ok(validator(doc)) } else { report.functional_validation( format!("Cannot retrieve a document {doc_ref}").as_str(), - "Validation data provider could not return a corresponding document.", + CONTEXT, ); Ok(false) } } + +/// Validate the document references +/// Document all possible error in doc report (no fail fast) +pub(crate) async fn validate_doc_refs( + doc_refs: &DocumentRefs, provider: &Provider, report: &ProblemReport, validator: Validator, +) -> anyhow::Result +where + Provider: CatalystSignedDocumentProvider, + Validator: Fn(CatalystSignedDocument) -> bool, +{ + let mut all_valid = true; + + for dr in doc_refs.doc_refs() { + let is_valid = validate_provided_doc(dr, provider, report, &validator).await?; + if !is_valid { + all_valid = false; + } + } + Ok(all_valid) +} diff --git a/rust/signed_doc/tests/comment.rs b/rust/signed_doc/tests/comment.rs index 5c92a0e481..6c5ca8c5c8 100644 --- a/rust/signed_doc/tests/comment.rs +++ b/rust/signed_doc/tests/comment.rs @@ -1,4 +1,6 @@ -//! Integration test for comment document validation part. +//! Test for Proposal Comment document. +//! Require fields: type, id, ver, ref, template, parameters +//! use catalyst_signed_doc::{ doc_types::deprecated, providers::tests::TestCatalystSignedDocumentProvider, *, @@ -7,155 +9,226 @@ use catalyst_types::catalyst_id::role_index::RoleId; mod common; +// Given a proposal comment document `doc`: +// +// - Parameters: +// The `parameters` field in `doc` points to a brand document. +// The parameter rule defines the link reference as `template`, This mean the document +// that `ref` field in `doc` points to (in this case = template_doc), must have the same +// `parameters` value as `doc`. +// +// - Reply: +// The `reply` field in `doc` points to another comment (`ref_doc`). +// The rule requires that the `ref` field in `ref_doc` must match the `ref` field in `doc` #[tokio::test] async fn test_valid_comment_doc() { let (proposal_doc, proposal_doc_id, proposal_doc_ver) = - common::create_dummy_doc(deprecated::PROPOSAL_DOCUMENT_UUID_TYPE).unwrap(); - let (template_doc, template_doc_id, template_doc_ver) = - common::create_dummy_doc(deprecated::COMMENT_TEMPLATE_UUID_TYPE).unwrap(); + common::create_dummy_doc(&doc_types::PROPOSAL.clone()).unwrap(); + let (brand_doc, brand_doc_id, brand_doc_ver) = + common::create_dummy_doc(&doc_types::BRAND_PARAMETERS.clone()).unwrap(); - let uuid_v7 = UuidV7::new(); - let (doc, ..) = common::create_dummy_signed_doc( - serde_json::json!({ + let ref_doc_id = UuidV7::new(); + let ref_doc_ver = UuidV7::new(); + + let template_doc_id = UuidV7::new(); + let template_doc_ver = UuidV7::new(); + + // Create a ref document, which is a proposal comment type + let (ref_doc, ..) = common::create_dummy_signed_doc( + serde_json::json!(serde_json::json!({ "content-type": ContentType::Json.to_string(), "content-encoding": ContentEncoding::Brotli.to_string(), "type": doc_types::PROPOSAL_COMMENT.clone(), - "id": uuid_v7.to_string(), - "ver": uuid_v7.to_string(), + "id": ref_doc_id.to_string(), + "ver": ref_doc_ver.to_string(), + "ref": { + "id": proposal_doc_id, + "ver": proposal_doc_ver + }, "template": { "id": template_doc_id, "ver": template_doc_ver }, + "parameters": { "id": brand_doc_id, "ver": brand_doc_ver }})), + serde_json::to_vec(&serde_json::Value::Null).unwrap(), + RoleId::Role0, + ) + .unwrap(); + + // Create a template document + let (template_doc, ..) = common::create_dummy_signed_doc( + serde_json::json!(serde_json::json!({ + "content-type": ContentType::Json.to_string(), + "content-encoding": ContentEncoding::Brotli.to_string(), + "type": doc_types::PROPOSAL_COMMENT_TEMPLATE.clone(), + "id": template_doc_id.to_string(), + "ver": template_doc_ver.to_string(), + "parameters": { "id": brand_doc_id, "ver": brand_doc_ver } + })), + serde_json::to_vec(&serde_json::json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + })) + .unwrap(), + RoleId::Role0, + ) + .unwrap(); + + let comment_doc_id = UuidV7::new(); + let comment_doc_ver = UuidV7::new(); + // Create a main comment doc, contain all fields mention in the document (except + // revocations and section) + let (doc, ..) = common::create_dummy_signed_doc( + serde_json::json!(serde_json::json!({ + "content-type": ContentType::Json.to_string(), + "content-encoding": ContentEncoding::Brotli.to_string(), + "type": doc_types::PROPOSAL_COMMENT.clone(), + "id": comment_doc_id.to_string(), + "ver": comment_doc_ver.to_string(), "ref": { "id": proposal_doc_id, "ver": proposal_doc_ver - } - }), - serde_json::to_vec(&serde_json::Value::Null).unwrap(), + }, + "template": { + "id": template_doc_id, + "ver": template_doc_ver + }, + "reply": { + "id": ref_doc_id, + "ver": ref_doc_ver + }, + "parameters": { "id": brand_doc_id, "ver": brand_doc_ver } + })), + // Validate against the ref template + serde_json::to_vec(&serde_json::json!({})).unwrap(), RoleId::Role0, ) .unwrap(); - let mut provider = TestCatalystSignedDocumentProvider::default(); - provider.add_document(template_doc).unwrap(); - provider.add_document(proposal_doc).unwrap(); - let is_valid = validator::validate(&doc, &provider).await.unwrap(); + provider.add_document(None, &brand_doc).unwrap(); + provider.add_document(None, &proposal_doc).unwrap(); + provider.add_document(None, &ref_doc).unwrap(); + provider.add_document(None, &template_doc).unwrap(); - assert!(is_valid); + let is_valid = validator::validate(&doc, &provider).await.unwrap(); + assert!(is_valid, "{:?}", doc.problem_report()); } +// The same as above but test with the old type #[tokio::test] async fn test_valid_comment_doc_old_type() { let (proposal_doc, proposal_doc_id, proposal_doc_ver) = - common::create_dummy_doc(deprecated::PROPOSAL_DOCUMENT_UUID_TYPE).unwrap(); - let (template_doc, template_doc_id, template_doc_ver) = - common::create_dummy_doc(deprecated::COMMENT_TEMPLATE_UUID_TYPE).unwrap(); + common::create_dummy_doc(&deprecated::PROPOSAL_DOCUMENT_UUID_TYPE.try_into().unwrap()) + .unwrap(); + let (brand_doc, brand_doc_id, brand_doc_ver) = + common::create_dummy_doc(&doc_types::BRAND_PARAMETERS.clone()).unwrap(); - let uuid_v7 = UuidV7::new(); - let (doc, ..) = common::create_dummy_signed_doc( - serde_json::json!({ + let ref_doc_id = UuidV7::new(); + let ref_doc_ver = UuidV7::new(); + + let template_doc_id = UuidV7::new(); + let template_doc_ver = UuidV7::new(); + + // Create a ref document, which is a proposal comment type + let (ref_doc, ..) = common::create_dummy_signed_doc( + serde_json::json!(serde_json::json!({ "content-type": ContentType::Json.to_string(), "content-encoding": ContentEncoding::Brotli.to_string(), - // Using old (single uuid) "type": deprecated::COMMENT_DOCUMENT_UUID_TYPE, - "id": uuid_v7.to_string(), - "ver": uuid_v7.to_string(), + "id": ref_doc_id.to_string(), + "ver": ref_doc_ver.to_string(), + "ref": { + "id": proposal_doc_id, + "ver": proposal_doc_ver + }, "template": { "id": template_doc_id, "ver": template_doc_ver }, - "ref": { - "id": proposal_doc_id, - "ver": proposal_doc_ver - } - }), + "parameters": { "id": brand_doc_id, "ver": brand_doc_ver }})), serde_json::to_vec(&serde_json::Value::Null).unwrap(), RoleId::Role0, ) .unwrap(); - let mut provider = TestCatalystSignedDocumentProvider::default(); - provider.add_document(template_doc).unwrap(); - provider.add_document(proposal_doc).unwrap(); - - let is_valid = validator::validate(&doc, &provider).await.unwrap(); - - assert!(is_valid); -} - -#[tokio::test] -async fn test_valid_comment_doc_with_reply() { - let empty_json = serde_json::to_vec(&serde_json::json!({})).unwrap(); - - let (proposal_doc, proposal_doc_id, proposal_doc_ver) = - common::create_dummy_doc(deprecated::PROPOSAL_DOCUMENT_UUID_TYPE).unwrap(); - let (template_doc, template_doc_id, template_doc_ver) = - common::create_dummy_doc(deprecated::COMMENT_TEMPLATE_UUID_TYPE).unwrap(); + // Create a template document + let (template_doc, ..) = common::create_dummy_signed_doc( + serde_json::json!(serde_json::json!({ + "content-type": ContentType::Json.to_string(), + "content-encoding": ContentEncoding::Brotli.to_string(), + "type": doc_types::PROPOSAL_COMMENT_TEMPLATE.clone(), + "id": template_doc_id.to_string(), + "ver": template_doc_ver.to_string(), + "parameters": { "id": brand_doc_id, "ver": brand_doc_ver } + })), + serde_json::to_vec(&serde_json::json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + })) + .unwrap(), + RoleId::Role0, + ) + .unwrap(); let comment_doc_id = UuidV7::new(); let comment_doc_ver = UuidV7::new(); - let comment_doc = Builder::new() - .with_json_metadata(serde_json::json!({ - "id": comment_doc_id, - "ver": comment_doc_ver, - "type": doc_types::PROPOSAL_COMMENT.clone(), + // Create a main comment doc, Contain all fields mention in the document (except + // revocations and section) + let (doc, ..) = common::create_dummy_signed_doc( + serde_json::json!(serde_json::json!({ "content-type": ContentType::Json.to_string(), - "template": { "id": template_doc_id.to_string(), "ver": template_doc_ver.to_string() }, + "content-encoding": ContentEncoding::Brotli.to_string(), + "type": deprecated::COMMENT_DOCUMENT_UUID_TYPE.clone(), + "id": comment_doc_id.to_string(), + "ver": comment_doc_ver.to_string(), "ref": { "id": proposal_doc_id, "ver": proposal_doc_ver }, - })) - .unwrap() - .with_decoded_content(empty_json.clone()) - .unwrap() - .build(); - - let uuid_v7 = UuidV7::new(); - let (doc, ..) = common::create_dummy_signed_doc( - serde_json::json!({ - "content-type": ContentType::Json.to_string(), - "content-encoding": ContentEncoding::Brotli.to_string(), - "type": doc_types::PROPOSAL_COMMENT.clone(), - "id": uuid_v7.to_string(), - "ver": uuid_v7.to_string(), "template": { "id": template_doc_id, "ver": template_doc_ver }, - "ref": { - "id": proposal_doc_id, - "ver": proposal_doc_ver - }, "reply": { - "id": comment_doc_id, - "ver": comment_doc_ver - } - }), - serde_json::to_vec(&serde_json::Value::Null).unwrap(), + "id": ref_doc_id, + "ver": ref_doc_ver + }, + "parameters": { "id": brand_doc_id, "ver": brand_doc_ver } + })), + // Validate against the ref template + serde_json::to_vec(&serde_json::json!({})).unwrap(), RoleId::Role0, ) .unwrap(); - let mut provider = TestCatalystSignedDocumentProvider::default(); - provider.add_document(template_doc).unwrap(); - provider.add_document(proposal_doc).unwrap(); - provider.add_document(comment_doc).unwrap(); - let is_valid = validator::validate(&doc, &provider).await.unwrap(); + provider.add_document(None, &brand_doc).unwrap(); + provider.add_document(None, &proposal_doc).unwrap(); + provider.add_document(None, &ref_doc).unwrap(); + provider.add_document(None, &template_doc).unwrap(); - assert!(is_valid); + let is_valid = validator::validate(&doc, &provider).await.unwrap(); + assert!(is_valid, "{:?}", doc.problem_report()); } #[tokio::test] async fn test_invalid_comment_doc() { let (proposal_doc, ..) = - common::create_dummy_doc(deprecated::PROPOSAL_DOCUMENT_UUID_TYPE).unwrap(); + common::create_dummy_doc(&deprecated::PROPOSAL_DOCUMENT_UUID_TYPE.try_into().unwrap()) + .unwrap(); let (template_doc, template_doc_id, template_doc_ver) = - common::create_dummy_doc(deprecated::COMMENT_TEMPLATE_UUID_TYPE).unwrap(); + common::create_dummy_doc(&deprecated::COMMENT_TEMPLATE_UUID_TYPE.try_into().unwrap()) + .unwrap(); let uuid_v7 = UuidV7::new(); + // Missing parameters field let (doc, ..) = common::create_dummy_signed_doc( serde_json::json!({ "content-type": ContentType::Json.to_string(), @@ -167,7 +240,6 @@ async fn test_invalid_comment_doc() { "id": template_doc_id, "ver": template_doc_ver }, - // without ref "ref": serde_json::Value::Null }), serde_json::to_vec(&serde_json::Value::Null).unwrap(), @@ -176,8 +248,8 @@ async fn test_invalid_comment_doc() { .unwrap(); let mut provider = TestCatalystSignedDocumentProvider::default(); - provider.add_document(template_doc).unwrap(); - provider.add_document(proposal_doc).unwrap(); + provider.add_document(None, &template_doc).unwrap(); + provider.add_document(None, &proposal_doc).unwrap(); let is_valid = validator::validate(&doc, &provider).await.unwrap(); diff --git a/rust/signed_doc/tests/common/mod.rs b/rust/signed_doc/tests/common/mod.rs index 2d964049ae..44ced02991 100644 --- a/rust/signed_doc/tests/common/mod.rs +++ b/rust/signed_doc/tests/common/mod.rs @@ -68,7 +68,7 @@ pub fn create_dummy_key_pair( } pub fn create_dummy_doc( - doc_type_id: Uuid, + doc_type_id: &DocType, ) -> anyhow::Result<(CatalystSignedDocument, UuidV7, UuidV7)> { let empty_json = serde_json::to_vec(&serde_json::json!({}))?; diff --git a/rust/signed_doc/tests/decoding.rs b/rust/signed_doc/tests/decoding.rs index 1ec2661a62..d0a5a1fffe 100644 --- a/rust/signed_doc/tests/decoding.rs +++ b/rust/signed_doc/tests/decoding.rs @@ -175,7 +175,13 @@ fn decoding_empty_bytes_case() -> TestCase { fn signed_doc_with_all_fields_case() -> TestCase { let uuid_v7 = UuidV7::new(); let uuid_v4 = UuidV4::new(); - + let dr: DocumentRefs = vec![DocumentRef::new( + UuidV7::new(), + UuidV7::new(), + DocLocator::default(), + )] + .into(); + let check_dr = dr.clone(); TestCase { name: "Catalyst Signed Doc with minimally defined metadata fields, signed (one signature), CBOR tagged.", bytes_gen: Box::new({ @@ -188,12 +194,15 @@ fn signed_doc_with_all_fields_case() -> TestCase { // protected headers (metadata fields) let mut p_headers = Encoder::new(Vec::new()); - p_headers.map(4)?; + p_headers.map(8)?; p_headers.u8(3)?.encode(ContentType::Json)?; p_headers.str("type")?.encode_with(uuid_v4, &mut catalyst_types::uuid::CborContext::Tagged)?; p_headers.str("id")?.encode_with(uuid_v7, &mut catalyst_types::uuid::CborContext::Tagged)?; p_headers.str("ver")?.encode_with(uuid_v7, &mut catalyst_types::uuid::CborContext::Tagged)?; - + p_headers.str("ref")?.encode_with(dr.clone(), &mut ())?; + p_headers.str("reply")?.encode_with(dr.clone(), &mut ())?; + p_headers.str("template")?.encode_with(dr.clone(), &mut ())?; + p_headers.str("parameters")?.encode_with(dr.clone(), &mut ())?; e.bytes(p_headers.into_writer().as_slice())?; // empty unprotected headers e.map(0)?; @@ -220,6 +229,10 @@ fn signed_doc_with_all_fields_case() -> TestCase { && (doc.doc_id().unwrap() == uuid_v7) && (doc.doc_ver().unwrap() == uuid_v7) && (doc.doc_content_type().unwrap() == ContentType::Json) + && doc.doc_meta().doc_ref().unwrap() == &check_dr + && doc.doc_meta().template().unwrap() == &check_dr + && doc.doc_meta().reply().unwrap() == &check_dr + && doc.doc_meta().parameters().unwrap() == &check_dr && (doc.encoded_content() == serde_json::to_vec(&serde_json::Value::Null).unwrap()) && doc.kids().len() == 1 } diff --git a/rust/signed_doc/tests/proposal.rs b/rust/signed_doc/tests/proposal.rs index 5cc071fd01..4b4adf5ad8 100644 --- a/rust/signed_doc/tests/proposal.rs +++ b/rust/signed_doc/tests/proposal.rs @@ -1,35 +1,80 @@ //! Integration test for proposal document validation part. +//! Require fields: type, id, ver, template, parameters +//! -use catalyst_signed_doc::{providers::tests::TestCatalystSignedDocumentProvider, *}; +use catalyst_signed_doc::{ + doc_types::deprecated, providers::tests::TestCatalystSignedDocumentProvider, *, +}; use catalyst_types::catalyst_id::role_index::RoleId; mod common; +// Given a proposal document `doc`: +// +// - Parameters: +// The `parameters` field in `doc` points to a brand document. +// The parameter rule defines the link reference as `template`, This mean the document +// that `ref` field in `doc` points to (in this case = `template_doc`), must have the same +// `parameters` value as `doc`. #[tokio::test] async fn test_valid_proposal_doc() { - let (template_doc, template_doc_id, template_doc_ver) = - common::create_dummy_doc(doc_types::deprecated::PROPOSAL_TEMPLATE_UUID_TYPE).unwrap(); + let (brand_doc, brand_doc_id, brand_doc_ver) = + common::create_dummy_doc(&doc_types::BRAND_PARAMETERS.clone()).unwrap(); - let uuid_v7 = UuidV7::new(); + let template_doc_id = UuidV7::new(); + let template_doc_ver = UuidV7::new(); + // Create a template document + let (template_doc, ..) = common::create_dummy_signed_doc( + serde_json::json!(serde_json::json!({ + "content-type": ContentType::Json.to_string(), + "content-encoding": ContentEncoding::Brotli.to_string(), + "type": doc_types::PROPOSAL_TEMPLATE.clone(), + "id": template_doc_id.to_string(), + "ver": template_doc_ver.to_string(), + "parameters": { "id": brand_doc_id, "ver": brand_doc_ver } + })), + serde_json::to_vec(&serde_json::json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + })) + .unwrap(), + RoleId::Role0, + ) + .unwrap(); + + let proposal_doc_id = UuidV7::new(); + let proposal_doc_ver = UuidV7::new(); + // Create a main proposal doc, contain all fields mention in the document (except + // collaborations and revocations) let (doc, ..) = common::create_dummy_signed_doc( serde_json::json!({ "content-type": ContentType::Json.to_string(), "content-encoding": ContentEncoding::Brotli.to_string(), "type": doc_types::PROPOSAL.clone(), - "id": uuid_v7.to_string(), - "ver": uuid_v7.to_string(), + "id": proposal_doc_id.to_string(), + "ver": proposal_doc_ver.to_string(), "template": { "id": template_doc_id, "ver": template_doc_ver }, + "parameters": { + "id": brand_doc_id, + "ver": brand_doc_ver + } }), - serde_json::to_vec(&serde_json::Value::Null).unwrap(), + // Validate against the ref template + serde_json::to_vec(&serde_json::json!({})).unwrap(), RoleId::Proposer, ) .unwrap(); let mut provider = TestCatalystSignedDocumentProvider::default(); - provider.add_document(template_doc).unwrap(); + + provider.add_document(None, &template_doc).unwrap(); + provider.add_document(None, &brand_doc).unwrap(); let is_valid = validator::validate(&doc, &provider).await.unwrap(); @@ -38,30 +83,62 @@ async fn test_valid_proposal_doc() { #[tokio::test] async fn test_valid_proposal_doc_old_type() { - let (template_doc, template_doc_id, template_doc_ver) = - common::create_dummy_doc(doc_types::deprecated::PROPOSAL_TEMPLATE_UUID_TYPE).unwrap(); + let (brand_doc, brand_doc_id, brand_doc_ver) = + common::create_dummy_doc(&doc_types::BRAND_PARAMETERS.clone()).unwrap(); - let uuid_v7 = UuidV7::new(); + let template_doc_id = UuidV7::new(); + let template_doc_ver = UuidV7::new(); + // Create a template document + let (template_doc, ..) = common::create_dummy_signed_doc( + serde_json::json!(serde_json::json!({ + "content-type": ContentType::Json.to_string(), + "content-encoding": ContentEncoding::Brotli.to_string(), + "type": doc_types::PROPOSAL_TEMPLATE.clone(), + "id": template_doc_id.to_string(), + "ver": template_doc_ver.to_string(), + "parameters": { "id": brand_doc_id, "ver": brand_doc_ver } + })), + serde_json::to_vec(&serde_json::json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + })) + .unwrap(), + RoleId::Role0, + ) + .unwrap(); + + let proposal_doc_id = UuidV7::new(); + let proposal_doc_ver = UuidV7::new(); + // Create a main proposal doc, contain all fields mention in the document (except + // collaborations and revocations) let (doc, ..) = common::create_dummy_signed_doc( serde_json::json!({ "content-type": ContentType::Json.to_string(), "content-encoding": ContentEncoding::Brotli.to_string(), - // Using old (single uuid) - "type": doc_types::deprecated::PROPOSAL_DOCUMENT_UUID_TYPE, - "id": uuid_v7.to_string(), - "ver": uuid_v7.to_string(), + "type": deprecated::PROPOSAL_DOCUMENT_UUID_TYPE.clone(), + "id": proposal_doc_id.to_string(), + "ver": proposal_doc_ver.to_string(), "template": { "id": template_doc_id, "ver": template_doc_ver }, + "parameters": { + "id": brand_doc_id, + "ver": brand_doc_ver + } }), - serde_json::to_vec(&serde_json::Value::Null).unwrap(), + serde_json::to_vec(&serde_json::json!({})).unwrap(), RoleId::Proposer, ) .unwrap(); let mut provider = TestCatalystSignedDocumentProvider::default(); - provider.add_document(template_doc).unwrap(); + + provider.add_document(None, &template_doc).unwrap(); + provider.add_document(None, &brand_doc).unwrap(); let is_valid = validator::validate(&doc, &provider).await.unwrap(); diff --git a/rust/signed_doc/tests/signature.rs b/rust/signed_doc/tests/signature.rs index ab8d7c7e15..86f4201dcd 100644 --- a/rust/signed_doc/tests/signature.rs +++ b/rust/signed_doc/tests/signature.rs @@ -33,7 +33,7 @@ async fn single_signature_validation_test() { ); // case: missing signatures - let (unsigned_doc, ..) = common::create_dummy_doc(UuidV4::new().into()).unwrap(); + let (unsigned_doc, ..) = common::create_dummy_doc(&UuidV4::new().into()).unwrap(); assert!(!validator::validate_signatures(&unsigned_doc, &provider) .await .unwrap()); diff --git a/rust/signed_doc/tests/submission.rs b/rust/signed_doc/tests/submission.rs index 5601f51c75..68620254fd 100644 --- a/rust/signed_doc/tests/submission.rs +++ b/rust/signed_doc/tests/submission.rs @@ -1,4 +1,6 @@ -//! Test for proposal submission action. +//! Test for Proposal Submission Action. +//! Require fields: type, id, ver, ref, parameters +//! use catalyst_signed_doc::{ doc_types::deprecated, providers::tests::TestCatalystSignedDocumentProvider, *, @@ -7,23 +9,56 @@ use catalyst_types::catalyst_id::role_index::RoleId; mod common; +// Given a proposal comment document `doc`: +// +// - Parameters: +// The `parameters` field in `doc` points to a brand document. +// The parameter rule defines the link reference as `ref`, This mean the document that +// `ref` field in `doc` points to (in this case = `proposal_doc`), must have the same +// `parameters` value as `doc`. #[tokio::test] async fn test_valid_submission_action() { - let (proposal_doc, proposal_doc_id, proposal_doc_ver) = - common::create_dummy_doc(deprecated::PROPOSAL_DOCUMENT_UUID_TYPE).unwrap(); + let (brand_doc, brand_doc_id, brand_doc_ver) = + common::create_dummy_doc(&doc_types::BRAND_PARAMETERS.clone()).unwrap(); - let uuid_v7 = UuidV7::new(); + let proposal_doc_id = UuidV7::new(); + let proposal_doc_ver = UuidV7::new(); + let (proposal_doc, ..) = common::create_dummy_signed_doc( + serde_json::json!({ + "content-type": ContentType::Json.to_string(), + "content-encoding": ContentEncoding::Brotli.to_string(), + "type": doc_types::PROPOSAL.clone(), + "id": proposal_doc_id.to_string(), + "ver": proposal_doc_ver.to_string(), + "ref": { + "id": UuidV7::new(), + "ver": UuidV7::new() + }, + "parameters": { "id": brand_doc_id, "ver": brand_doc_ver } + }), + serde_json::to_vec(&serde_json::json!({ + "action": "final" + })) + .unwrap(), + RoleId::Proposer, + ) + .unwrap(); + + let doc_id = UuidV7::new(); + let doc_ver = UuidV7::new(); + // Create a main proposal submission doc, contain all fields mention in the document let (doc, ..) = common::create_dummy_signed_doc( serde_json::json!({ "content-type": ContentType::Json.to_string(), "content-encoding": ContentEncoding::Brotli.to_string(), "type": doc_types::PROPOSAL_SUBMISSION_ACTION.clone(), - "id": uuid_v7.to_string(), - "ver": uuid_v7.to_string(), + "id": doc_id.to_string(), + "ver": doc_ver.to_string(), "ref": { "id": proposal_doc_id, "ver": proposal_doc_ver }, + "parameters": { "id": brand_doc_id, "ver": brand_doc_ver } }), serde_json::to_vec(&serde_json::json!({ "action": "final" @@ -34,29 +69,57 @@ async fn test_valid_submission_action() { .unwrap(); let mut provider = TestCatalystSignedDocumentProvider::default(); - provider.add_document(proposal_doc).unwrap(); + + provider.add_document(None, &proposal_doc).unwrap(); + provider.add_document(None, &brand_doc).unwrap(); + let is_valid = validator::validate(&doc, &provider).await.unwrap(); assert!(is_valid, "{:?}", doc.problem_report()); } #[tokio::test] async fn test_valid_submission_action_old_type() { - let (proposal_doc, proposal_doc_id, proposal_doc_ver) = - common::create_dummy_doc(deprecated::PROPOSAL_DOCUMENT_UUID_TYPE).unwrap(); + let (brand_doc, brand_doc_id, brand_doc_ver) = + common::create_dummy_doc(&doc_types::BRAND_PARAMETERS.clone()).unwrap(); - let uuid_v7 = UuidV7::new(); + let proposal_doc_id = UuidV7::new(); + let proposal_doc_ver = UuidV7::new(); + let (proposal_doc, ..) = common::create_dummy_signed_doc( + serde_json::json!({ + "content-type": ContentType::Json.to_string(), + "content-encoding": ContentEncoding::Brotli.to_string(), + "type": deprecated::PROPOSAL_DOCUMENT_UUID_TYPE.clone(), + "id": proposal_doc_id.to_string(), + "ver": proposal_doc_ver.to_string(), + "ref": { + "id": UuidV7::new(), + "ver": UuidV7::new() + }, + "parameters": { "id": brand_doc_id, "ver": brand_doc_ver } + }), + serde_json::to_vec(&serde_json::json!({ + "action": "final" + })) + .unwrap(), + RoleId::Proposer, + ) + .unwrap(); + + let doc_id = UuidV7::new(); + let doc_ver = UuidV7::new(); + // Create a main proposal submission doc, contain all fields mention in the document let (doc, ..) = common::create_dummy_signed_doc( serde_json::json!({ "content-type": ContentType::Json.to_string(), "content-encoding": ContentEncoding::Brotli.to_string(), - // Using old (single uuid) - "type": deprecated::PROPOSAL_ACTION_DOCUMENT_UUID_TYPE, - "id": uuid_v7.to_string(), - "ver": uuid_v7.to_string(), + "type": deprecated::PROPOSAL_ACTION_DOCUMENT_UUID_TYPE.clone(), + "id": doc_id.to_string(), + "ver": doc_ver.to_string(), "ref": { "id": proposal_doc_id, "ver": proposal_doc_ver }, + "parameters": { "id": brand_doc_id, "ver": brand_doc_ver } }), serde_json::to_vec(&serde_json::json!({ "action": "final" @@ -67,7 +130,10 @@ async fn test_valid_submission_action_old_type() { .unwrap(); let mut provider = TestCatalystSignedDocumentProvider::default(); - provider.add_document(proposal_doc).unwrap(); + + provider.add_document(None, &proposal_doc).unwrap(); + provider.add_document(None, &brand_doc).unwrap(); + let is_valid = validator::validate(&doc, &provider).await.unwrap(); assert!(is_valid, "{:?}", doc.problem_report()); } @@ -101,7 +167,6 @@ async fn test_valid_submission_action_with_empty_provider() { let provider = TestCatalystSignedDocumentProvider::default(); let is_valid = validator::validate(&doc, &provider).await.unwrap(); - assert!(!is_valid); } @@ -116,7 +181,6 @@ async fn test_invalid_submission_action() { "type": doc_types::PROPOSAL_SUBMISSION_ACTION.clone(), "id": uuid_v7.to_string(), "ver": uuid_v7.to_string(), - // without specifying ref "ref": serde_json::Value::Null, }), serde_json::to_vec(&serde_json::json!({ @@ -133,7 +197,8 @@ async fn test_invalid_submission_action() { // corrupted JSON let (proposal_doc, proposal_doc_id, proposal_doc_ver) = - common::create_dummy_doc(deprecated::PROPOSAL_DOCUMENT_UUID_TYPE).unwrap(); + common::create_dummy_doc(&deprecated::PROPOSAL_DOCUMENT_UUID_TYPE.try_into().unwrap()) + .unwrap(); let uuid_v7 = UuidV7::new(); let (doc, ..) = common::create_dummy_signed_doc( serde_json::json!({ @@ -153,13 +218,16 @@ async fn test_invalid_submission_action() { .unwrap(); let mut provider = TestCatalystSignedDocumentProvider::default(); - provider.add_document(proposal_doc).unwrap(); + + provider.add_document(None, &proposal_doc).unwrap(); + let is_valid = validator::validate(&doc, &provider).await.unwrap(); assert!(!is_valid); // empty content let (proposal_doc, proposal_doc_id, proposal_doc_ver) = - common::create_dummy_doc(deprecated::PROPOSAL_DOCUMENT_UUID_TYPE).unwrap(); + common::create_dummy_doc(&deprecated::PROPOSAL_DOCUMENT_UUID_TYPE.try_into().unwrap()) + .unwrap(); let uuid_v7 = UuidV7::new(); let (doc, ..) = common::create_dummy_signed_doc( serde_json::json!({ @@ -179,7 +247,9 @@ async fn test_invalid_submission_action() { .unwrap(); let mut provider = TestCatalystSignedDocumentProvider::default(); - provider.add_document(proposal_doc).unwrap(); + + provider.add_document(None, &proposal_doc).unwrap(); + let is_valid = validator::validate(&doc, &provider).await.unwrap(); assert!(!is_valid); }