diff --git a/rust/catalyst-types/src/uuid/mod.rs b/rust/catalyst-types/src/uuid/mod.rs
index 3e25737e1d..c2df1c4795 100644
--- a/rust/catalyst-types/src/uuid/mod.rs
+++ b/rust/catalyst-types/src/uuid/mod.rs
@@ -15,8 +15,7 @@ use minicbor::data::Tag;
pub const INVALID_UUID: uuid::Uuid = uuid::Uuid::from_bytes([0x00; 16]);
/// UUID CBOR tag .
-#[allow(dead_code)]
-const UUID_CBOR_TAG: u64 = 37;
+pub const UUID_CBOR_TAG: u64 = 37;
/// Uuid validation errors, which could occur during decoding or converting to
/// `UuidV4` or `UuidV7` types.
@@ -29,6 +28,9 @@ pub enum UuidError {
/// `UUIDv7` invalid error
#[error("'{0}' is not a valid UUIDv7")]
InvalidUuidV7(uuid::Uuid),
+ /// Invalid string conversion
+ #[error("Invalid string conversion: {0}")]
+ StringConversion(String),
}
/// Context for `CBOR` encoding and decoding
diff --git a/rust/catalyst-types/src/uuid/uuid_v4.rs b/rust/catalyst-types/src/uuid/uuid_v4.rs
index c7e2dbb814..a7baf46248 100644
--- a/rust/catalyst-types/src/uuid/uuid_v4.rs
+++ b/rust/catalyst-types/src/uuid/uuid_v4.rs
@@ -1,5 +1,8 @@
//! `UUIDv4` Type.
-use std::fmt::{Display, Formatter};
+use std::{
+ fmt::{Display, Formatter},
+ str::FromStr,
+};
use minicbor::{Decode, Decoder, Encode};
use uuid::Uuid;
@@ -7,7 +10,7 @@ use uuid::Uuid;
use super::{decode_cbor_uuid, encode_cbor_uuid, CborContext, UuidError, INVALID_UUID};
/// Type representing a `UUIDv4`.
-#[derive(Copy, Clone, Debug, PartialEq, PartialOrd, serde::Serialize)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, serde::Serialize)]
pub struct UuidV4(Uuid);
impl UuidV4 {
@@ -106,6 +109,15 @@ impl<'de> serde::Deserialize<'de> for UuidV4 {
}
}
+impl FromStr for UuidV4 {
+ type Err = UuidError;
+
+ fn from_str(s: &str) -> Result {
+ let uuid = Uuid::parse_str(s).map_err(|_| UuidError::StringConversion(s.to_string()))?;
+ UuidV4::try_from(uuid).map_err(|_| UuidError::InvalidUuidV4(uuid))
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/rust/catalyst-types/src/uuid/uuid_v7.rs b/rust/catalyst-types/src/uuid/uuid_v7.rs
index 98fbd8cda6..1bb95e6ff9 100644
--- a/rust/catalyst-types/src/uuid/uuid_v7.rs
+++ b/rust/catalyst-types/src/uuid/uuid_v7.rs
@@ -1,5 +1,8 @@
//! `UUIDv7` Type.
-use std::fmt::{Display, Formatter};
+use std::{
+ fmt::{Display, Formatter},
+ str::FromStr,
+};
use minicbor::{Decode, Decoder, Encode};
use uuid::Uuid;
@@ -106,6 +109,15 @@ impl<'de> serde::Deserialize<'de> for UuidV7 {
}
}
+impl FromStr for UuidV7 {
+ type Err = UuidError;
+
+ fn from_str(s: &str) -> Result {
+ let uuid = Uuid::parse_str(s).map_err(|_| UuidError::StringConversion(s.to_string()))?;
+ UuidV7::try_from(uuid).map_err(|_| UuidError::InvalidUuidV7(uuid))
+ }
+}
+
#[cfg(test)]
mod tests {
use uuid::Uuid;
diff --git a/rust/signed_doc/Cargo.toml b/rust/signed_doc/Cargo.toml
index 62c4c6b8f7..151de0a34f 100644
--- a/rust/signed_doc/Cargo.toml
+++ b/rust/signed_doc/Cargo.toml
@@ -15,19 +15,20 @@ catalyst-types = { version = "0.0.3", path = "../catalyst-types" }
anyhow = "1.0.95"
serde = { version = "1.0.217", features = ["derive"] }
-serde_json = "1.0.134"
+serde_json = { version = "1.0.134", features = ["raw_value"] }
coset = "0.3.8"
minicbor = { version = "0.25.1", features = ["half"] }
brotli = "7.0.0"
ed25519-dalek = { version = "2.1.1", features = ["rand_core", "pem"] }
hex = "0.4.3"
-strum = { version = "0.26.3", features = ["derive"] }
+strum = { version = "0.27.1", features = ["derive"] }
clap = { version = "4.5.23", features = ["derive", "env"] }
jsonschema = "0.28.3"
jsonpath-rust = "0.7.5"
futures = "0.3.31"
ed25519-bip32 = "0.4.1" # used by the `mk_signed_doc` cli tool
-
+tracing = "0.1.40"
+thiserror = "2.0.11"
[dev-dependencies]
base64-url = "3.0.0"
diff --git a/rust/signed_doc/src/decode_context.rs b/rust/signed_doc/src/decode_context.rs
new file mode 100644
index 0000000000..6c85e9c247
--- /dev/null
+++ b/rust/signed_doc/src/decode_context.rs
@@ -0,0 +1,25 @@
+//! Context used to pass in decoder for additional information.
+
+use catalyst_types::problem_report::ProblemReport;
+
+/// Compatibility policy
+#[allow(dead_code)]
+#[derive(Copy, Clone)]
+pub(crate) enum CompatibilityPolicy {
+ /// Silently allow obsoleted type conversions or non deterministic encoding.
+ Accept,
+ /// Allow but log Warnings for all obsoleted type conversions or non deterministic
+ /// encoding.
+ Warn,
+ /// Fail and update problem report when an obsolete type is encountered or the data is
+ /// not deterministically encoded.
+ Fail,
+}
+
+/// A context use to pass to decoder.
+pub(crate) struct DecodeContext<'r> {
+ /// Compatibility policy.
+ pub compatibility_policy: CompatibilityPolicy,
+ /// Problem report.
+ pub report: &'r mut ProblemReport,
+}
diff --git a/rust/signed_doc/src/doc_types/mod.rs b/rust/signed_doc/src/doc_types/mod.rs
index 683e36db2f..2a2c22735e 100644
--- a/rust/signed_doc/src/doc_types/mod.rs
+++ b/rust/signed_doc/src/doc_types/mod.rs
@@ -1,53 +1,108 @@
//! An implementation of different defined document types
//!
+use std::sync::LazyLock;
+
use catalyst_types::uuid::Uuid;
+use deprecated::{
+ COMMENT_DOCUMENT_UUID_TYPE, PROPOSAL_ACTION_DOCUMENT_UUID_TYPE, PROPOSAL_DOCUMENT_UUID_TYPE,
+};
+
+use crate::DocType;
+
+/// Proposal document type.
+#[allow(clippy::expect_used)]
+pub static PROPOSAL_DOC_TYPE: LazyLock = LazyLock::new(|| {
+ let ids = &[PROPOSAL_UUID_TYPE];
+ ids.to_vec()
+ .try_into()
+ .expect("Failed to convert proposal document Uuid to DocType")
+});
+
+/// Proposal comment document type.
+#[allow(clippy::expect_used)]
+pub static PROPOSAL_COMMENT_DOC: LazyLock = LazyLock::new(|| {
+ let ids = &[COMMENT_UUID_TYPE, PROPOSAL_UUID_TYPE];
+ ids.to_vec()
+ .try_into()
+ .expect("Failed to convert proposal comment document Uuid to DocType")
+});
+
+/// Proposal action document type.
+#[allow(clippy::expect_used)]
+pub static PROPOSAL_ACTION_DOC: LazyLock = LazyLock::new(|| {
+ let ids = &[
+ ACTION_UUID_TYPE,
+ PROPOSAL_UUID_TYPE,
+ SUBMISSION_ACTION_UUID_TYPE,
+ ];
+ ids.to_vec()
+ .try_into()
+ .expect("Failed to convert proposal action document Uuid to DocType")
+});
+
+/// Submission Action UUID type.
+pub const SUBMISSION_ACTION_UUID_TYPE: Uuid =
+ Uuid::from_u128(0x7892_7329_CFD9_4EA1_9C71_0E01_9B12_6A65);
+/// Proposal UUID type.
+pub const PROPOSAL_UUID_TYPE: Uuid = PROPOSAL_DOCUMENT_UUID_TYPE;
+/// Comment UUID type.
+pub const COMMENT_UUID_TYPE: Uuid = COMMENT_DOCUMENT_UUID_TYPE;
+/// Action UUID type.
+pub const ACTION_UUID_TYPE: Uuid = PROPOSAL_ACTION_DOCUMENT_UUID_TYPE;
+
+/// Document type which will be deprecated.
+pub mod deprecated {
+ use catalyst_types::uuid::Uuid;
-/// Proposal document `UuidV4` type.
-pub const PROPOSAL_DOCUMENT_UUID_TYPE: Uuid =
- Uuid::from_u128(0x7808_D2BA_D511_40AF_84E8_C0D1_625F_DFDC);
-/// Proposal template `UuidV4` type.
-pub const PROPOSAL_TEMPLATE_UUID_TYPE: Uuid =
- Uuid::from_u128(0x0CE8_AB38_9258_4FBC_A62E_7FAA_6E58_318F);
-/// Comment document `UuidV4` type.
-pub const COMMENT_DOCUMENT_UUID_TYPE: Uuid =
- Uuid::from_u128(0xB679_DED3_0E7C_41BA_89F8_DA62_A178_98EA);
-/// Comment template `UuidV4` type.
-pub const COMMENT_TEMPLATE_UUID_TYPE: Uuid =
- Uuid::from_u128(0x0B84_24D4_EBFD_46E3_9577_1775_A69D_290C);
-/// Review document `UuidV4` type.
-pub const REVIEW_DOCUMENT_UUID_TYPE: Uuid =
- Uuid::from_u128(0xE4CA_F5F0_098B_45FD_94F3_0702_A457_3DB5);
-/// Review template `UuidV4` type.
-pub const REVIEW_TEMPLATE_UUID_TYPE: Uuid =
- Uuid::from_u128(0xEBE5_D0BF_5D86_4577_AF4D_008F_DDBE_2EDC);
-/// Category document `UuidV4` type.
-pub const CATEGORY_DOCUMENT_UUID_TYPE: Uuid =
- Uuid::from_u128(0x48C2_0109_362A_4D32_9BBA_E0A9_CF8B_45BE);
-/// Category template `UuidV4` type.
-pub const CATEGORY_TEMPLATE_UUID_TYPE: Uuid =
- Uuid::from_u128(0x65B1_E8B0_51F1_46A5_9970_72CD_F268_84BE);
-/// Campaign parameters document `UuidV4` type.
-pub const CAMPAIGN_DOCUMENT_UUID_TYPE: Uuid =
- Uuid::from_u128(0x0110_EA96_A555_47CE_8408_36EF_E6ED_6F7C);
-/// Campaign parameters template `UuidV4` type.
-pub const CAMPAIGN_TEMPLATE_UUID_TYPE: Uuid =
- Uuid::from_u128(0x7E8F_5FA2_44CE_49C8_BFD5_02AF_42C1_79A3);
-/// Brand parameters document `UuidV4` type.
-pub const BRAND_DOCUMENT_UUID_TYPE: Uuid =
- Uuid::from_u128(0x3E48_08CC_C86E_467B_9702_D60B_AA9D_1FCA);
-/// Brand parameters template `UuidV4` type.
-pub const BRAND_TEMPLATE_UUID_TYPE: Uuid =
- Uuid::from_u128(0xFD3C_1735_80B1_4EEA_8D63_5F43_6D97_EA31);
-/// Proposal action document `UuidV4` type.
-pub const PROPOSAL_ACTION_DOCUMENT_UUID_TYPE: Uuid =
- Uuid::from_u128(0x5E60_E623_AD02_4A1B_A1AC_406D_B978_EE48);
-/// Public vote transaction v2 `UuidV4` type.
-pub const PUBLIC_VOTE_TX_V2_UUID_TYPE: Uuid =
- Uuid::from_u128(0x8DE5_586C_E998_4B95_8742_7BE3_C859_2803);
-/// Private vote transaction v2 `UuidV4` type.
-pub const PRIVATE_VOTE_TX_V2_UUID_TYPE: Uuid =
- Uuid::from_u128(0xE78E_E18D_F380_44C1_A852_80AA_6ECB_07FE);
-/// Immutable ledger block `UuidV4` type.
-pub const IMMUTABLE_LEDGER_BLOCK_UUID_TYPE: Uuid =
- Uuid::from_u128(0xD9E7_E6CE_2401_4D7D_9492_F4F7_C642_41C3);
+ /// Proposal document `UuidV4` type.
+ pub const PROPOSAL_DOCUMENT_UUID_TYPE: Uuid =
+ Uuid::from_u128(0x7808_D2BA_D511_40AF_84E8_C0D1_625F_DFDC);
+ /// Proposal template `UuidV4` type.
+ pub const PROPOSAL_TEMPLATE_UUID_TYPE: Uuid =
+ Uuid::from_u128(0x0CE8_AB38_9258_4FBC_A62E_7FAA_6E58_318F);
+ /// Comment document `UuidV4` type.
+ pub const COMMENT_DOCUMENT_UUID_TYPE: Uuid =
+ Uuid::from_u128(0xB679_DED3_0E7C_41BA_89F8_DA62_A178_98EA);
+ /// Comment template `UuidV4` type.
+ pub const COMMENT_TEMPLATE_UUID_TYPE: Uuid =
+ Uuid::from_u128(0x0B84_24D4_EBFD_46E3_9577_1775_A69D_290C);
+ /// Review document `UuidV4` type.
+ pub const REVIEW_DOCUMENT_UUID_TYPE: Uuid =
+ Uuid::from_u128(0xE4CA_F5F0_098B_45FD_94F3_0702_A457_3DB5);
+ /// Review template `UuidV4` type.
+ pub const REVIEW_TEMPLATE_UUID_TYPE: Uuid =
+ Uuid::from_u128(0xEBE5_D0BF_5D86_4577_AF4D_008F_DDBE_2EDC);
+ /// Category document `UuidV4` type.
+ pub const CATEGORY_DOCUMENT_UUID_TYPE: Uuid =
+ Uuid::from_u128(0x48C2_0109_362A_4D32_9BBA_E0A9_CF8B_45BE);
+ /// Category template `UuidV4` type.
+ pub const CATEGORY_TEMPLATE_UUID_TYPE: Uuid =
+ Uuid::from_u128(0x65B1_E8B0_51F1_46A5_9970_72CD_F268_84BE);
+ /// Campaign parameters document `UuidV4` type.
+ pub const CAMPAIGN_DOCUMENT_UUID_TYPE: Uuid =
+ Uuid::from_u128(0x0110_EA96_A555_47CE_8408_36EF_E6ED_6F7C);
+ /// Campaign parameters template `UuidV4` type.
+ pub const CAMPAIGN_TEMPLATE_UUID_TYPE: Uuid =
+ Uuid::from_u128(0x7E8F_5FA2_44CE_49C8_BFD5_02AF_42C1_79A3);
+ /// Brand parameters document `UuidV4` type.
+ pub const BRAND_DOCUMENT_UUID_TYPE: Uuid =
+ Uuid::from_u128(0x3E48_08CC_C86E_467B_9702_D60B_AA9D_1FCA);
+ /// Brand parameters template `UuidV4` type.
+ pub const BRAND_TEMPLATE_UUID_TYPE: Uuid =
+ Uuid::from_u128(0xFD3C_1735_80B1_4EEA_8D63_5F43_6D97_EA31);
+ /// Proposal action document `UuidV4` type.
+ pub const PROPOSAL_ACTION_DOCUMENT_UUID_TYPE: Uuid =
+ Uuid::from_u128(0x5E60_E623_AD02_4A1B_A1AC_406D_B978_EE48);
+ /// Public vote transaction v2 `UuidV4` type.
+ pub const PUBLIC_VOTE_TX_V2_UUID_TYPE: Uuid =
+ Uuid::from_u128(0x8DE5_586C_E998_4B95_8742_7BE3_C859_2803);
+ /// Private vote transaction v2 `UuidV4` type.
+ pub const PRIVATE_VOTE_TX_V2_UUID_TYPE: Uuid =
+ Uuid::from_u128(0xE78E_E18D_F380_44C1_A852_80AA_6ECB_07FE);
+ /// Immutable ledger block `UuidV4` type.
+ pub const IMMUTABLE_LEDGER_BLOCK_UUID_TYPE: Uuid =
+ Uuid::from_u128(0xD9E7_E6CE_2401_4D7D_9492_F4F7_C642_41C3);
+ /// Submission Action `UuidV4` type.
+ pub const SUBMISSION_ACTION: Uuid = Uuid::from_u128(0x7892_7329_CFD9_4EA1_9C71_0E01_9B12_6A65);
+}
diff --git a/rust/signed_doc/src/lib.rs b/rust/signed_doc/src/lib.rs
index c5d5779733..c797d75138 100644
--- a/rust/signed_doc/src/lib.rs
+++ b/rust/signed_doc/src/lib.rs
@@ -2,6 +2,7 @@
mod builder;
mod content;
+mod decode_context;
pub mod doc_types;
mod metadata;
pub mod providers;
@@ -22,7 +23,8 @@ pub use catalyst_types::{
};
pub use content::Content;
use coset::{CborSerializable, Header, TaggedCborSerializable};
-pub use metadata::{ContentEncoding, ContentType, DocumentRef, ExtraFields, Metadata, Section};
+use decode_context::{CompatibilityPolicy, DecodeContext};
+pub use metadata::{ContentEncoding, ContentType, DocType, DocumentRef, Metadata, Section};
use minicbor::{decode, encode, Decode, Decoder, Encode};
pub use signature::{CatalystId, Signatures};
@@ -85,11 +87,11 @@ impl From for CatalystSignedDocument {
impl CatalystSignedDocument {
// A bunch of getters to access the contents, or reason through the document, such as.
- /// Return Document Type `UUIDv4`.
+ /// Return Document Type `DocType` - List of `UUIDv4`.
///
/// # Errors
/// - Missing 'type' field.
- pub fn doc_type(&self) -> anyhow::Result {
+ pub fn doc_type(&self) -> anyhow::Result<&DocType> {
self.inner.metadata.doc_type()
}
@@ -130,9 +132,10 @@ impl CatalystSignedDocument {
}
/// Return document metadata content.
+ // TODO: remove this and provide getters from metadata like the rest of its fields have.
#[must_use]
- pub fn doc_meta(&self) -> &ExtraFields {
- self.inner.metadata.extra()
+ pub fn doc_meta(&self) -> &Metadata {
+ &self.inner.metadata
}
/// Return a Document's signatures
@@ -235,8 +238,12 @@ impl Decode<'_, ()> for CatalystSignedDocument {
minicbor::decode::Error::message(format!("Invalid COSE Sign document: {e}"))
})?;
- let report = ProblemReport::new(PROBLEM_REPORT_CTX);
- let metadata = Metadata::from_protected_header(&cose_sign.protected, &report);
+ let mut report = ProblemReport::new(PROBLEM_REPORT_CTX);
+ let mut ctx = DecodeContext {
+ compatibility_policy: CompatibilityPolicy::Accept,
+ report: &mut report,
+ };
+ let metadata = Metadata::from_protected_header(&cose_sign.protected, &mut ctx);
let signatures = Signatures::from_cose_sig_list(&cose_sign.signatures, &report);
let content = if let Some(payload) = cose_sign.payload {
diff --git a/rust/signed_doc/src/metadata/content_type.rs b/rust/signed_doc/src/metadata/content_type.rs
index b72cb4b9c2..199122eddc 100644
--- a/rust/signed_doc/src/metadata/content_type.rs
+++ b/rust/signed_doc/src/metadata/content_type.rs
@@ -18,25 +18,6 @@ pub enum ContentType {
Json,
}
-impl ContentType {
- /// Validates the provided `content` bytes to be a defined `ContentType`.
- pub(crate) fn validate(self, content: &[u8]) -> anyhow::Result<()> {
- match self {
- Self::Json => {
- if let Err(e) = serde_json::from_slice::(content) {
- anyhow::bail!("Invalid {self} content: {e}")
- }
- },
- Self::Cbor => {
- if let Err(e) = minicbor::decode::(content) {
- anyhow::bail!("Invalid {self} content: {e}")
- }
- },
- }
- Ok(())
- }
-}
-
impl Display for ContentType {
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> {
match self {
@@ -108,17 +89,6 @@ impl TryFrom<&coset::ContentType> for ContentType {
mod tests {
use super::*;
- #[test]
- fn content_type_validate_test() {
- let json_bytes = serde_json::to_vec(&serde_json::Value::Null).unwrap();
- assert!(ContentType::Json.validate(&json_bytes).is_ok());
- assert!(ContentType::Cbor.validate(&json_bytes).is_err());
-
- let cbor_bytes = minicbor::to_vec(minicbor::data::Token::Null).unwrap();
- assert!(ContentType::Json.validate(&cbor_bytes).is_err());
- assert!(ContentType::Cbor.validate(&cbor_bytes).is_ok());
- }
-
#[test]
fn content_type_string_test() {
assert_eq!(
diff --git a/rust/signed_doc/src/metadata/doc_type.rs b/rust/signed_doc/src/metadata/doc_type.rs
new file mode 100644
index 0000000000..a7c2ca205b
--- /dev/null
+++ b/rust/signed_doc/src/metadata/doc_type.rs
@@ -0,0 +1,513 @@
+//! Document Type.
+
+use std::{
+ fmt::{Display, Formatter},
+ hash::{Hash, Hasher},
+};
+
+use catalyst_types::{
+ problem_report::ProblemReport,
+ uuid::{CborContext, Uuid, UuidV4, UUID_CBOR_TAG},
+};
+use coset::cbor::Value;
+use minicbor::{Decode, Decoder, Encode};
+use serde::{Deserialize, Deserializer};
+use tracing::warn;
+
+use crate::{
+ decode_context::{CompatibilityPolicy, DecodeContext},
+ doc_types::{
+ ACTION_UUID_TYPE, COMMENT_UUID_TYPE, PROPOSAL_ACTION_DOC, PROPOSAL_COMMENT_DOC,
+ PROPOSAL_DOC_TYPE, PROPOSAL_UUID_TYPE,
+ },
+};
+
+/// List of `UUIDv4` document type.
+#[derive(Clone, Debug, serde::Serialize, Eq)]
+pub struct DocType(Vec);
+
+/// `DocType` Errors.
+#[derive(Debug, Clone, thiserror::Error)]
+pub enum DocTypeError {
+ /// Invalid UUID.
+ #[error("Invalid UUID: {0}")]
+ InvalidUuid(Uuid),
+ /// `DocType` cannot be empty.
+ #[error("DocType cannot be empty")]
+ Empty,
+ /// Invalid string conversion
+ #[error("Invalid string conversion: {0}")]
+ StringConversion(String),
+}
+
+impl DocType {
+ /// Get a list of `UUIDv4` document types.
+ #[must_use]
+ pub fn doc_types(&self) -> &Vec {
+ &self.0
+ }
+
+ /// Convert `DocType` to coset `Value`.
+ pub(crate) fn to_value(&self) -> Value {
+ Value::Array(
+ self.0
+ .iter()
+ .map(|uuidv4| {
+ Value::Tag(
+ UUID_CBOR_TAG,
+ Box::new(Value::Bytes(uuidv4.uuid().as_bytes().to_vec())),
+ )
+ })
+ .collect(),
+ )
+ }
+}
+
+impl Hash for DocType {
+ fn hash(&self, state: &mut H) {
+ let list = self
+ .0
+ .iter()
+ .map(std::string::ToString::to_string)
+ .collect::>();
+ list.hash(state);
+ }
+}
+
+impl From for DocType {
+ fn from(value: UuidV4) -> Self {
+ DocType(vec![value])
+ }
+}
+
+impl TryFrom for DocType {
+ type Error = DocTypeError;
+
+ fn try_from(value: Uuid) -> Result {
+ let uuid_v4 = UuidV4::try_from(value).map_err(|_| DocTypeError::InvalidUuid(value))?;
+ Ok(DocType(vec![uuid_v4]))
+ }
+}
+
+impl TryFrom> for DocType {
+ type Error = DocTypeError;
+
+ fn try_from(value: Vec) -> Result {
+ if value.is_empty() {
+ return Err(DocTypeError::Empty);
+ }
+
+ let converted = value
+ .into_iter()
+ .map(|u| UuidV4::try_from(u).map_err(|_| DocTypeError::InvalidUuid(u)))
+ .collect::, DocTypeError>>()?;
+
+ DocType::try_from(converted)
+ }
+}
+
+impl TryFrom> for DocType {
+ type Error = DocTypeError;
+
+ fn try_from(value: Vec) -> Result {
+ if value.is_empty() {
+ return Err(DocTypeError::Empty);
+ }
+ Ok(DocType(value))
+ }
+}
+
+impl TryFrom> for DocType {
+ type Error = DocTypeError;
+
+ fn try_from(value: Vec) -> Result {
+ if value.is_empty() {
+ return Err(DocTypeError::Empty);
+ }
+ let converted = value
+ .into_iter()
+ .map(|s| {
+ s.parse::()
+ .map_err(|_| DocTypeError::StringConversion(s))
+ })
+ .collect::, _>>()?;
+
+ Ok(DocType(converted))
+ }
+}
+
+impl Display for DocType {
+ fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> {
+ write!(
+ f,
+ "[{}]",
+ self.0
+ .iter()
+ .map(UuidV4::to_string)
+ .collect::>()
+ .join(", ")
+ )
+ }
+}
+
+// ; Document Type
+// document_type = [ 1* uuid_v4 ]
+// ; UUIDv4
+// uuid_v4 = #6.37(bytes .size 16)
+impl Decode<'_, DecodeContext<'_>> for DocType {
+ fn decode(
+ d: &mut Decoder, decode_context: &mut DecodeContext,
+ ) -> Result {
+ const CONTEXT: &str = "DocType decoding";
+ let parse_uuid = |d: &mut Decoder| UuidV4::decode(d, &mut CborContext::Tagged);
+
+ match d.datatype()? {
+ minicbor::data::Type::Array => {
+ let len = 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 len == 0 {
+ decode_context.report.invalid_value(
+ "array length",
+ "0",
+ "must contain at least one UUIDv4",
+ CONTEXT,
+ );
+ return Err(minicbor::decode::Error::message(format!(
+ "{CONTEXT}: empty array"
+ )));
+ }
+
+ (0..len)
+ .map(|_| parse_uuid(d))
+ .collect::, _>>()
+ .map(Self)
+ .map_err(|e| {
+ decode_context
+ .report
+ .other(&format!("Invalid UUIDv4 in array: {e}"), CONTEXT);
+ minicbor::decode::Error::message(format!(
+ "{CONTEXT}: Invalid UUIDv4 in array: {e}"
+ ))
+ })
+ },
+ minicbor::data::Type::Tag => {
+ // Handle single tagged UUID
+ match decode_context.compatibility_policy {
+ CompatibilityPolicy::Accept | CompatibilityPolicy::Warn => {
+ if matches!(
+ decode_context.compatibility_policy,
+ CompatibilityPolicy::Warn
+ ) {
+ warn!("{CONTEXT}: Conversion of document type single UUID to type DocType");
+ }
+
+ let uuid = parse_uuid(d).map_err(|e| {
+ let msg = format!("Cannot decode single UUIDv4: {e}");
+ decode_context.report.invalid_value(
+ "Decode single UUIDv4",
+ &e.to_string(),
+ &msg,
+ CONTEXT,
+ );
+ minicbor::decode::Error::message(format!("{CONTEXT}: {msg}"))
+ })?;
+
+ let doc_type = map_doc_type(uuid.into()).map_err(|e| {
+ decode_context.report.other(&e.to_string(), CONTEXT);
+ minicbor::decode::Error::message(format!("{CONTEXT}: {e}"))
+ })?;
+
+ Ok(doc_type)
+ },
+
+ CompatibilityPolicy::Fail => {
+ let msg = "Conversion of document type single UUID to type DocType 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",
+ &format!("{other:?}"),
+ "array or tag cbor",
+ CONTEXT,
+ );
+ Err(minicbor::decode::Error::message(format!(
+ "{CONTEXT}: expected array of UUIDor tagged UUIDv4, got {other:?}",
+ )))
+ },
+ }
+ }
+}
+
+/// Map single UUID doc type to new list of doc types
+///
+fn map_doc_type(uuid: Uuid) -> anyhow::Result {
+ match uuid {
+ id if id == PROPOSAL_UUID_TYPE => Ok(PROPOSAL_DOC_TYPE.clone()),
+ id if id == COMMENT_UUID_TYPE => Ok(PROPOSAL_COMMENT_DOC.clone()),
+ id if id == ACTION_UUID_TYPE => Ok(PROPOSAL_ACTION_DOC.clone()),
+ _ => anyhow::bail!("Unknown document type: {uuid}"),
+ }
+}
+
+impl Encode for DocType {
+ fn encode(
+ &self, e: &mut minicbor::Encoder, report: &mut ProblemReport,
+ ) -> Result<(), minicbor::encode::Error> {
+ const CONTEXT: &str = "DocType encoding";
+ if self.0.is_empty() {
+ report.invalid_value("DocType", "empty", "DocType cannot be empty", CONTEXT);
+ return Err(minicbor::encode::Error::message(format!(
+ "{CONTEXT}: DocType cannot be empty"
+ )));
+ }
+
+ e.array(self.0.len().try_into().map_err(|_| {
+ report.other("Unable to encode array length", CONTEXT);
+ minicbor::encode::Error::message(format!("{CONTEXT}, unable to encode array length"))
+ })?)?;
+
+ for id in &self.0 {
+ id.encode(e, &mut CborContext::Tagged).map_err(|_| {
+ report.other("Failed to encode UUIDv4", CONTEXT);
+ minicbor::encode::Error::message(format!("{CONTEXT}: UUIDv4 encoding failed"))
+ })?;
+ }
+ Ok(())
+ }
+}
+
+impl<'de> Deserialize<'de> for DocType {
+ fn deserialize(deserializer: D) -> Result
+ where D: Deserializer<'de> {
+ #[derive(Deserialize)]
+ #[serde(untagged)]
+ enum DocTypeInput {
+ /// Single UUID string.
+ Single(String),
+ /// List of UUID string.
+ Multiple(Vec),
+ }
+
+ let input = DocTypeInput::deserialize(deserializer)?;
+ let dt = match input {
+ DocTypeInput::Single(s) => {
+ let uuid = Uuid::parse_str(&s).map_err(|_| {
+ serde::de::Error::custom(DocTypeError::StringConversion(s.clone()))
+ })?;
+ // If there is a map from old (single uuid) to new use that list, else convert that
+ // single uuid to [uuid] - of type DocType
+ map_doc_type(uuid).unwrap_or(uuid.try_into().map_err(serde::de::Error::custom)?)
+ },
+ DocTypeInput::Multiple(v) => v.try_into().map_err(serde::de::Error::custom)?,
+ };
+ Ok(dt)
+ }
+}
+
+// This is needed to preserve backward compatibility with the old solution.
+impl PartialEq for DocType {
+ fn eq(&self, other: &Self) -> bool {
+ // List of special-case (single UUID) -> new DocType
+ // The old one should equal to the new one
+ let special_cases = [
+ (PROPOSAL_UUID_TYPE, &*PROPOSAL_DOC_TYPE),
+ (COMMENT_UUID_TYPE, &*PROPOSAL_COMMENT_DOC),
+ (ACTION_UUID_TYPE, &*PROPOSAL_ACTION_DOC),
+ ];
+ for (uuid, expected) in special_cases {
+ match DocType::try_from(uuid) {
+ Ok(single) => {
+ if (self.0 == single.0 && other.0 == expected.0)
+ || (other.0 == single.0 && self.0 == expected.0)
+ {
+ return true;
+ }
+ },
+ Err(_) => return false,
+ }
+ }
+ self.0 == other.0
+ }
+}
+
+#[cfg(test)]
+mod tests {
+
+ use minicbor::Encoder;
+ use serde_json::json;
+
+ use super::*;
+
+ //
+ // Proposal Submission Action = 37(h'5e60e623ad024a1ba1ac406db978ee48') should map to
+ // [37(h'5e60e623ad024a1ba1ac406db978ee48'), 37(h'7808d2bad51140af84e8c0d1625fdfdc'),
+ // 37(h'78927329cfd94ea19c710e019b126a65')]
+ const PSA: &str = "D825505E60E623AD024A1BA1AC406DB978EE48";
+
+ #[test]
+ fn test_empty_doc_type_cbor_decode() {
+ assert!(>>::try_from(vec![]).is_err());
+
+ let mut report = ProblemReport::new("Test empty doc type");
+ let mut decoded_context = DecodeContext {
+ compatibility_policy: CompatibilityPolicy::Accept,
+ report: &mut report,
+ };
+ let mut decoder = Decoder::new(&[]);
+ assert!(DocType::decode(&mut decoder, &mut decoded_context).is_err());
+ }
+
+ #[test]
+ fn test_single_uuid_doc_type_fail_policy_cbor_decode() {
+ let mut report = ProblemReport::new("Test single uuid doc type - fail");
+ let data = hex::decode(PSA).unwrap();
+ let decoder = Decoder::new(&data);
+ let mut decoded_context = DecodeContext {
+ compatibility_policy: CompatibilityPolicy::Fail,
+ report: &mut report,
+ };
+ assert!(DocType::decode(&mut decoder.clone(), &mut decoded_context).is_err());
+ }
+
+ #[test]
+ fn test_single_uuid_doc_type_warn_policy_cbor_decode() {
+ let mut report = ProblemReport::new("Test single uuid doc type - warn");
+ let data = hex::decode(PSA).unwrap();
+ let decoder = Decoder::new(&data);
+ let mut decoded_context = DecodeContext {
+ compatibility_policy: CompatibilityPolicy::Warn,
+ report: &mut report,
+ };
+ let decoded_doc_type = DocType::decode(&mut decoder.clone(), &mut decoded_context).unwrap();
+ assert_eq!(decoded_doc_type.doc_types().len(), 3);
+ }
+
+ #[test]
+ fn test_single_uuid_doc_type_accept_policy_cbor_decode() {
+ let mut report = ProblemReport::new("Test single uuid doc type - accept");
+ let data = hex::decode(PSA).unwrap();
+ let decoder = Decoder::new(&data);
+ let mut decoded_context = DecodeContext {
+ compatibility_policy: CompatibilityPolicy::Accept,
+ report: &mut report,
+ };
+ let decoded_doc_type = DocType::decode(&mut decoder.clone(), &mut decoded_context).unwrap();
+ assert_eq!(decoded_doc_type.doc_types().len(), 3);
+ }
+
+ #[test]
+ fn test_multi_uuid_doc_type_cbor_decode_encode() {
+ let uuidv4 = UuidV4::new();
+ let mut report = ProblemReport::new("Test multi uuid doc type");
+ let doc_type_list: DocType = vec![uuidv4, uuidv4].try_into().unwrap();
+ let mut buffer = Vec::new();
+ let mut encoder = Encoder::new(&mut buffer);
+ doc_type_list.encode(&mut encoder, &mut report).unwrap();
+ let mut decoder = Decoder::new(&buffer);
+ let mut decoded_context = DecodeContext {
+ compatibility_policy: CompatibilityPolicy::Accept,
+ report: &mut report.clone(),
+ };
+ let decoded_doc_type = DocType::decode(&mut decoder, &mut decoded_context).unwrap();
+ assert_eq!(decoded_doc_type, doc_type_list);
+ }
+
+ #[test]
+ fn test_valid_vec_string() {
+ let uuid = Uuid::new_v4().to_string();
+ let input = vec![uuid.clone()];
+ let doc_type = DocType::try_from(input).expect("should succeed");
+
+ assert_eq!(doc_type.0.len(), 1);
+ assert_eq!(doc_type.0.first().unwrap().to_string(), uuid);
+ }
+
+ #[test]
+ fn test_empty_vec_string_fails() {
+ let input: Vec = vec![];
+ let result = DocType::try_from(input);
+ assert!(matches!(result, Err(DocTypeError::Empty)));
+ }
+
+ #[test]
+ fn test_invalid_uuid_vec_string() {
+ let input = vec!["not-a-uuid".to_string()];
+ let result = DocType::try_from(input);
+ assert!(matches!(result, Err(DocTypeError::StringConversion(s)) if s == "not-a-uuid"));
+ }
+
+ #[test]
+ fn test_doc_type_to_value() {
+ let uuid = uuid::Uuid::new_v4();
+ let doc_type = DocType(vec![UuidV4::try_from(uuid).unwrap()]);
+
+ for d in &doc_type.to_value().into_array().unwrap() {
+ let t = d.clone().into_tag().unwrap();
+ assert_eq!(t.0, UUID_CBOR_TAG);
+ assert_eq!(t.1.as_bytes().unwrap().len(), 16);
+ }
+ }
+
+ #[test]
+ fn test_doctype_equal_special_cases() {
+ // Direct equal
+ let uuid = PROPOSAL_UUID_TYPE;
+ let dt1 = DocType::try_from(vec![uuid]).unwrap();
+ let dt2 = DocType::try_from(vec![uuid]).unwrap();
+ assert_eq!(dt1, dt2);
+
+ // single -> special mapped type
+ let single = DocType::try_from(PROPOSAL_UUID_TYPE).unwrap();
+ assert_eq!(single, *PROPOSAL_DOC_TYPE);
+ let single = DocType::try_from(COMMENT_UUID_TYPE).unwrap();
+ assert_eq!(single, *PROPOSAL_COMMENT_DOC);
+ let single = DocType::try_from(ACTION_UUID_TYPE).unwrap();
+ assert_eq!(single, *PROPOSAL_ACTION_DOC);
+ }
+
+ #[test]
+ fn test_deserialize_single_uuid_normal() {
+ let uuid = uuid::Uuid::new_v4().to_string();
+ let json = json!(uuid);
+ let dt: DocType = serde_json::from_value(json).unwrap();
+
+ assert_eq!(dt.0.len(), 1);
+ assert_eq!(dt.0.first().unwrap().to_string(), uuid);
+ }
+
+ #[test]
+ fn test_deserialize_multiple_uuids() {
+ let uuid1 = uuid::Uuid::new_v4().to_string();
+ let uuid2 = uuid::Uuid::new_v4().to_string();
+ let json = json!([uuid1.clone(), uuid2.clone()]);
+
+ let dt: DocType = serde_json::from_value(json).unwrap();
+ let actual =
+ dt.0.iter()
+ .map(std::string::ToString::to_string)
+ .collect::>();
+ assert_eq!(actual, vec![uuid1, uuid2]);
+ }
+
+ #[test]
+ fn test_deserialize_special_case() {
+ let uuid = PROPOSAL_UUID_TYPE.to_string();
+ let json = json!(uuid);
+ let dt: DocType = serde_json::from_value(json).unwrap();
+
+ assert_eq!(dt, *PROPOSAL_DOC_TYPE);
+ }
+}
diff --git a/rust/signed_doc/src/metadata/extra_fields.rs b/rust/signed_doc/src/metadata/extra_fields.rs
deleted file mode 100644
index 5decc4a784..0000000000
--- a/rust/signed_doc/src/metadata/extra_fields.rs
+++ /dev/null
@@ -1,239 +0,0 @@
-//! Catalyst Signed Document Extra Fields.
-
-use catalyst_types::problem_report::ProblemReport;
-use coset::{cbor::Value, Label, ProtectedHeader};
-
-use super::{
- cose_protected_header_find, utils::decode_document_field_from_protected_header, DocumentRef,
- Section,
-};
-
-/// `ref` field COSE key value
-const REF_KEY: &str = "ref";
-/// `template` field COSE key value
-const TEMPLATE_KEY: &str = "template";
-/// `reply` field COSE key value
-const REPLY_KEY: &str = "reply";
-/// `section` field COSE key value
-const SECTION_KEY: &str = "section";
-/// `collabs` field COSE key value
-const COLLABS_KEY: &str = "collabs";
-/// `parameters` field COSE key value
-const PARAMETERS_KEY: &str = "parameters";
-/// `brand_id` field COSE key value (alias of the `parameters` field)
-const BRAND_ID_KEY: &str = "brand_id";
-/// `campaign_id` field COSE key value (alias of the `parameters` field)
-const CAMPAIGN_ID_KEY: &str = "campaign_id";
-/// `category_id` field COSE key value (alias of the `parameters` field)
-const CATEGORY_ID_KEY: &str = "category_id";
-
-/// Extra Metadata Fields.
-///
-/// These values are extracted from the COSE Sign protected header labels.
-#[derive(Clone, Default, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
-pub struct ExtraFields {
- /// Reference to the latest document.
- #[serde(rename = "ref", skip_serializing_if = "Option::is_none")]
- doc_ref: Option,
- /// Reference to the document template.
- #[serde(skip_serializing_if = "Option::is_none")]
- template: Option,
- /// Reference to the document reply.
- #[serde(skip_serializing_if = "Option::is_none")]
- reply: Option,
- /// Reference to the document section.
- #[serde(skip_serializing_if = "Option::is_none")]
- section: Option,
- /// Reference to the document collaborators. Collaborator type is TBD.
- #[serde(default = "Vec::new", skip_serializing_if = "Vec::is_empty")]
- collabs: Vec,
- /// Reference to the parameters document.
- #[serde(skip_serializing_if = "Option::is_none")]
- parameters: Option,
-}
-
-impl ExtraFields {
- /// Return `ref` field.
- #[must_use]
- pub fn doc_ref(&self) -> Option {
- self.doc_ref
- }
-
- /// Return `template` field.
- #[must_use]
- pub fn template(&self) -> Option {
- self.template
- }
-
- /// Return `reply` field.
- #[must_use]
- pub fn reply(&self) -> Option {
- self.reply
- }
-
- /// Return `section` field.
- #[must_use]
- pub fn section(&self) -> Option<&Section> {
- self.section.as_ref()
- }
-
- /// Return `collabs` field.
- #[must_use]
- pub fn collabs(&self) -> &Vec {
- &self.collabs
- }
-
- /// Return `parameters` field.
- #[must_use]
- pub fn parameters(&self) -> Option {
- self.parameters
- }
-
- /// Fill the COSE header `ExtraFields` data into the header builder.
- pub(super) fn fill_cose_header_fields(
- &self, mut builder: coset::HeaderBuilder,
- ) -> anyhow::Result {
- if let Some(doc_ref) = &self.doc_ref {
- builder = builder.text_value(REF_KEY.to_string(), Value::try_from(*doc_ref)?);
- }
- if let Some(template) = &self.template {
- builder = builder.text_value(TEMPLATE_KEY.to_string(), Value::try_from(*template)?);
- }
- if let Some(reply) = &self.reply {
- builder = builder.text_value(REPLY_KEY.to_string(), Value::try_from(*reply)?);
- }
-
- if let Some(section) = &self.section {
- builder = builder.text_value(SECTION_KEY.to_string(), Value::from(section.clone()));
- }
-
- if !self.collabs.is_empty() {
- builder = builder.text_value(
- COLLABS_KEY.to_string(),
- Value::Array(self.collabs.iter().cloned().map(Value::Text).collect()),
- );
- }
-
- if let Some(parameters) = &self.parameters {
- builder = builder.text_value(PARAMETERS_KEY.to_string(), Value::try_from(*parameters)?);
- }
-
- Ok(builder)
- }
-
- /// Converting COSE Protected Header to `ExtraFields`.
- pub(crate) fn from_protected_header(
- protected: &ProtectedHeader, error_report: &ProblemReport,
- ) -> Self {
- /// Context for problem report messages during decoding from COSE protected
- /// header.
- const COSE_DECODING_CONTEXT: &str = "COSE ProtectedHeader to ExtraFields";
-
- let doc_ref = decode_document_field_from_protected_header(
- protected,
- REF_KEY,
- COSE_DECODING_CONTEXT,
- error_report,
- );
- let template = decode_document_field_from_protected_header(
- protected,
- TEMPLATE_KEY,
- COSE_DECODING_CONTEXT,
- error_report,
- );
- let reply = decode_document_field_from_protected_header(
- protected,
- REPLY_KEY,
- COSE_DECODING_CONTEXT,
- error_report,
- );
- let section = decode_document_field_from_protected_header(
- protected,
- SECTION_KEY,
- COSE_DECODING_CONTEXT,
- error_report,
- );
-
- // process `parameters` field and all its aliases
- let (parameters, has_multiple_fields) = [
- PARAMETERS_KEY,
- BRAND_ID_KEY,
- CAMPAIGN_ID_KEY,
- CATEGORY_ID_KEY,
- ]
- .iter()
- .filter_map(|field_name| -> Option {
- decode_document_field_from_protected_header(
- protected,
- field_name,
- COSE_DECODING_CONTEXT,
- error_report,
- )
- })
- .fold((None, false), |(res, _), v| (Some(v), res.is_some()));
- if has_multiple_fields {
- error_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"
- );
- }
-
- let mut extra = ExtraFields {
- doc_ref,
- template,
- reply,
- section,
- parameters,
- ..Default::default()
- };
-
- if let Some(cbor_doc_collabs) = cose_protected_header_find(protected, |key| {
- key == &Label::Text(COLLABS_KEY.to_string())
- }) {
- if let Ok(collabs) = cbor_doc_collabs.clone().into_array() {
- let mut c = Vec::new();
- for (ids, collaborator) in collabs.iter().cloned().enumerate() {
- match collaborator.clone().into_text() {
- Ok(collaborator) => {
- c.push(collaborator);
- },
- Err(_) => {
- error_report.conversion_error(
- &format!("COSE protected header collaborator index {ids}"),
- &format!("{collaborator:?}"),
- "Expected a CBOR String",
- &format!(
- "{COSE_DECODING_CONTEXT}, converting collaborator to String",
- ),
- );
- },
- }
- }
- extra.collabs = c;
- } else {
- error_report.conversion_error(
- "CBOR COSE protected header collaborators",
- &format!("{cbor_doc_collabs:?}"),
- "Expected a CBOR Array",
- &format!("{COSE_DECODING_CONTEXT}, converting collaborators to Array",),
- );
- };
- }
-
- extra
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn empty_extra_fields_json_serde_test() {
- let extra = ExtraFields::default();
-
- let json = serde_json::to_value(extra).unwrap();
- assert_eq!(json, serde_json::json!({}));
- }
-}
diff --git a/rust/signed_doc/src/metadata/mod.rs b/rust/signed_doc/src/metadata/mod.rs
index bbbdb1677d..10a2688081 100644
--- a/rust/signed_doc/src/metadata/mod.rs
+++ b/rust/signed_doc/src/metadata/mod.rs
@@ -1,25 +1,32 @@
//! Catalyst Signed Document Metadata.
-use std::fmt::{Display, Formatter};
+use std::{
+ collections::{btree_map, BTreeMap},
+ error::Error,
+ fmt::{Display, Formatter},
+};
mod content_encoding;
mod content_type;
+pub(crate) mod doc_type;
mod document_ref;
-mod extra_fields;
mod section;
+mod supported_field;
pub(crate) mod utils;
-use catalyst_types::{
- problem_report::ProblemReport,
- uuid::{UuidV4, UuidV7},
-};
+use catalyst_types::{problem_report::ProblemReport, uuid::UuidV7};
pub use content_encoding::ContentEncoding;
pub use content_type::ContentType;
-use coset::{cbor::Value, iana::CoapContentFormat};
+use coset::{cbor::Value, iana::CoapContentFormat, CborSerializable};
+pub use doc_type::DocType;
pub use document_ref::DocumentRef;
-pub use extra_fields::ExtraFields;
+use minicbor::{Decode, Decoder};
pub use section::Section;
-use utils::{
- cose_protected_header_find, decode_document_field_from_protected_header, CborUuidV4, CborUuidV7,
+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},
};
/// `content_encoding` field COSE key value
@@ -31,18 +38,39 @@ const ID_KEY: &str = "id";
/// `ver` field COSE key value
const VER_KEY: &str = "ver";
+/// `ref` field COSE key value
+const REF_KEY: &str = "ref";
+/// `template` field COSE key value
+const TEMPLATE_KEY: &str = "template";
+/// `reply` field COSE key value
+const REPLY_KEY: &str = "reply";
+/// `section` field COSE key value
+const SECTION_KEY: &str = "section";
+/// `collabs` field COSE key value
+const COLLABS_KEY: &str = "collabs";
+/// `parameters` field COSE key value
+const PARAMETERS_KEY: &str = "parameters";
+/// `brand_id` field COSE key value (alias of the `parameters` field)
+const BRAND_ID_KEY: &str = "brand_id";
+/// `campaign_id` field COSE key value (alias of the `parameters` field)
+const CAMPAIGN_ID_KEY: &str = "campaign_id";
+/// `category_id` field COSE key value (alias of the `parameters` field)
+const CATEGORY_ID_KEY: &str = "category_id";
+
/// Document Metadata.
///
/// These values are extracted from the COSE Sign protected header.
#[derive(Clone, Debug, PartialEq, Default)]
-pub struct Metadata(InnerMetadata);
+pub struct Metadata(BTreeMap);
/// An actual representation of all metadata fields.
+// TODO: this is maintained as an implementation of `serde` and `coset` for `Metadata`
+// and should be removed in case `serde` and `coset` are deprecated completely.
#[derive(Clone, Debug, PartialEq, serde::Deserialize, Default)]
pub(crate) struct InnerMetadata {
- /// Document Type `UUIDv4`.
+ /// Document Type, list of `UUIDv4`.
#[serde(rename = "type")]
- doc_type: Option,
+ doc_type: Option,
/// Document ID `UUIDv7`.
id: Option,
/// Document Version `UUIDv7`.
@@ -53,19 +81,56 @@ pub(crate) struct InnerMetadata {
/// Document Payload Content Encoding.
#[serde(rename = "content-encoding")]
content_encoding: Option,
- /// Additional Metadata Fields.
- #[serde(flatten)]
- extra: ExtraFields,
+ /// Reference to the latest document.
+ #[serde(rename = "ref", skip_serializing_if = "Option::is_none")]
+ doc_ref: Option,
+ /// Reference to the document template.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ template: Option,
+ /// Reference to the document reply.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ reply: Option,
+ /// Reference to the document section.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ section: Option,
+ /// Reference to the document collaborators. Collaborator type is TBD.
+ #[serde(default = "Vec::new", skip_serializing_if = "Vec::is_empty")]
+ collabs: Vec,
+ /// Reference to the parameters document.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ parameters: Option,
+}
+
+impl InnerMetadata {
+ /// Converts into an iterator over present fields fields.
+ fn into_iter(self) -> impl Iterator- {
+ [
+ self.doc_type.map(SupportedField::Type),
+ self.id.map(SupportedField::Id),
+ self.ver.map(SupportedField::Ver),
+ self.content_type.map(SupportedField::ContentType),
+ self.content_encoding.map(SupportedField::ContentEncoding),
+ self.doc_ref.map(SupportedField::Ref),
+ self.template.map(SupportedField::Template),
+ self.reply.map(SupportedField::Reply),
+ self.section.map(SupportedField::Section),
+ (!self.collabs.is_empty()).then_some(SupportedField::Collabs(self.collabs)),
+ self.parameters.map(SupportedField::Parameters),
+ ]
+ .into_iter()
+ .flatten()
+ }
}
impl Metadata {
- /// Return Document Type `UUIDv4`.
+ /// Return Document Type `DocType` - a list of `UUIDv4`.
///
/// # Errors
/// - Missing 'type' field.
- pub fn doc_type(&self) -> anyhow::Result {
+ pub fn doc_type(&self) -> anyhow::Result<&DocType> {
self.0
- .doc_type
+ .get(&SupportedLabel::Type)
+ .and_then(SupportedField::try_as_type_ref)
.ok_or(anyhow::anyhow!("Missing 'type' field"))
}
@@ -74,7 +139,11 @@ impl Metadata {
/// # Errors
/// - Missing 'id' field.
pub fn doc_id(&self) -> anyhow::Result {
- self.0.id.ok_or(anyhow::anyhow!("Missing 'id' field"))
+ self.0
+ .get(&SupportedLabel::Id)
+ .and_then(SupportedField::try_as_id_ref)
+ .copied()
+ .ok_or(anyhow::anyhow!("Missing 'id' field"))
}
/// Return Document Version `UUIDv7`.
@@ -82,7 +151,11 @@ impl Metadata {
/// # Errors
/// - Missing 'ver' field.
pub fn doc_ver(&self) -> anyhow::Result {
- self.0.ver.ok_or(anyhow::anyhow!("Missing 'ver' field"))
+ self.0
+ .get(&SupportedLabel::Ver)
+ .and_then(SupportedField::try_as_ver_ref)
+ .copied()
+ .ok_or(anyhow::anyhow!("Missing 'ver' field"))
}
/// Returns the Document Content Type, if any.
@@ -91,20 +164,72 @@ impl Metadata {
/// - Missing 'content-type' field.
pub fn content_type(&self) -> anyhow::Result {
self.0
- .content_type
+ .get(&SupportedLabel::ContentType)
+ .and_then(SupportedField::try_as_content_type_ref)
+ .copied()
.ok_or(anyhow::anyhow!("Missing 'content-type' field"))
}
/// Returns the Document Content Encoding, if any.
#[must_use]
pub fn content_encoding(&self) -> Option {
- self.0.content_encoding
+ self.0
+ .get(&SupportedLabel::ContentEncoding)
+ .and_then(SupportedField::try_as_content_encoding_ref)
+ .copied()
+ }
+
+ /// Return `ref` field.
+ #[must_use]
+ pub fn doc_ref(&self) -> Option {
+ self.0
+ .get(&SupportedLabel::Ref)
+ .and_then(SupportedField::try_as_ref_ref)
+ .copied()
+ }
+
+ /// Return `template` field.
+ #[must_use]
+ pub fn template(&self) -> Option {
+ self.0
+ .get(&SupportedLabel::Template)
+ .and_then(SupportedField::try_as_template_ref)
+ .copied()
+ }
+
+ /// Return `reply` field.
+ #[must_use]
+ pub fn reply(&self) -> Option {
+ self.0
+ .get(&SupportedLabel::Reply)
+ .and_then(SupportedField::try_as_reply_ref)
+ .copied()
+ }
+
+ /// Return `section` field.
+ #[must_use]
+ pub fn section(&self) -> Option<&Section> {
+ self.0
+ .get(&SupportedLabel::Section)
+ .and_then(SupportedField::try_as_section_ref)
}
- /// Return reference to additional metadata fields.
+ /// Return `collabs` field.
#[must_use]
- pub fn extra(&self) -> &ExtraFields {
- &self.0.extra
+ pub fn collabs(&self) -> &[String] {
+ self.0
+ .get(&SupportedLabel::Collabs)
+ .and_then(SupportedField::try_as_collabs_ref)
+ .map_or(&[], Vec::as_slice)
+ }
+
+ /// Return `parameters` field.
+ #[must_use]
+ pub fn parameters(&self) -> Option {
+ self.0
+ .get(&SupportedLabel::Parameters)
+ .and_then(SupportedField::try_as_parameters_ref)
+ .copied()
}
/// Build `Metadata` object from the metadata fields, doing all necessary validation.
@@ -126,39 +251,44 @@ impl Metadata {
);
}
- Self(metadata)
+ Self(
+ metadata
+ .into_iter()
+ .map(|field| (field.discriminant(), field))
+ .collect(),
+ )
}
/// Converting COSE Protected Header to Metadata.
pub(crate) fn from_protected_header(
- protected: &coset::ProtectedHeader, report: &ProblemReport,
+ protected: &coset::ProtectedHeader, context: &mut DecodeContext,
) -> Self {
- let metadata = InnerMetadata::from_protected_header(protected, report);
- Self::from_metadata_fields(metadata, report)
+ let metadata = InnerMetadata::from_protected_header(protected, context);
+ Self::from_metadata_fields(metadata, context.report)
}
}
impl InnerMetadata {
/// Converting COSE Protected Header to Metadata fields, collecting decoding report
/// issues.
+ #[allow(
+ clippy::too_many_lines,
+ reason = "This is a compilation of `coset` decoding and should be replaced once migrated to `minicbor`."
+ )]
pub(crate) fn from_protected_header(
- protected: &coset::ProtectedHeader, report: &ProblemReport,
+ protected: &coset::ProtectedHeader, context: &mut DecodeContext,
) -> Self {
/// Context for problem report messages during decoding from COSE protected
/// header.
const COSE_DECODING_CONTEXT: &str = "COSE Protected Header to Metadata";
- let extra = ExtraFields::from_protected_header(protected, report);
- let mut metadata = Self {
- extra,
- ..Self::default()
- };
+ let mut metadata = Self::default();
if let Some(value) = protected.header.content_type.as_ref() {
match ContentType::try_from(value) {
Ok(ct) => metadata.content_type = Some(ct),
Err(e) => {
- report.conversion_error(
+ context.report.conversion_error(
"COSE protected header content type",
&format!("{value:?}"),
&format!("Expected ContentType: {e}"),
@@ -175,7 +305,7 @@ impl InnerMetadata {
match ContentEncoding::try_from(value) {
Ok(ce) => metadata.content_encoding = Some(ce),
Err(e) => {
- report.conversion_error(
+ context.report.conversion_error(
"COSE protected header content encoding",
&format!("{value:?}"),
&format!("Expected ContentEncoding: {e}"),
@@ -185,19 +315,23 @@ impl InnerMetadata {
}
}
- metadata.doc_type = decode_document_field_from_protected_header::(
+ metadata.doc_type = cose_protected_header_find(
protected,
- TYPE_KEY,
- COSE_DECODING_CONTEXT,
- report,
+ |key| matches!(key, coset::Label::Text(label) if label.eq_ignore_ascii_case(TYPE_KEY)),
)
- .map(|v| v.0);
+ .and_then(|value| {
+ DocType::decode(
+ &mut Decoder::new(&value.clone().to_vec().unwrap_or_default()),
+ context,
+ )
+ .ok()
+ });
metadata.id = decode_document_field_from_protected_header::(
protected,
ID_KEY,
COSE_DECODING_CONTEXT,
- report,
+ context.report,
)
.map(|v| v.0);
@@ -205,10 +339,94 @@ impl InnerMetadata {
protected,
VER_KEY,
COSE_DECODING_CONTEXT,
- report,
+ context.report,
)
.map(|v| v.0);
+ metadata.doc_ref = decode_document_field_from_protected_header(
+ protected,
+ REF_KEY,
+ COSE_DECODING_CONTEXT,
+ context.report,
+ );
+ metadata.template = decode_document_field_from_protected_header(
+ protected,
+ TEMPLATE_KEY,
+ COSE_DECODING_CONTEXT,
+ context.report,
+ );
+ metadata.reply = decode_document_field_from_protected_header(
+ protected,
+ REPLY_KEY,
+ COSE_DECODING_CONTEXT,
+ context.report,
+ );
+ metadata.section = decode_document_field_from_protected_header(
+ protected,
+ SECTION_KEY,
+ COSE_DECODING_CONTEXT,
+ context.report,
+ );
+
+ // process `parameters` field and all its aliases
+ let (parameters, has_multiple_fields) = [
+ PARAMETERS_KEY,
+ BRAND_ID_KEY,
+ CAMPAIGN_ID_KEY,
+ CATEGORY_ID_KEY,
+ ]
+ .iter()
+ .filter_map(|field_name| -> Option {
+ decode_document_field_from_protected_header(
+ protected,
+ field_name,
+ COSE_DECODING_CONTEXT,
+ context.report,
+ )
+ })
+ .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"
+ );
+ }
+ metadata.parameters = parameters;
+
+ if let Some(cbor_doc_collabs) = cose_protected_header_find(protected, |key| {
+ key == &coset::Label::Text(COLLABS_KEY.to_string())
+ }) {
+ if let Ok(collabs) = cbor_doc_collabs.clone().into_array() {
+ let mut c = Vec::new();
+ for (ids, collaborator) in collabs.iter().cloned().enumerate() {
+ match collaborator.clone().into_text() {
+ Ok(collaborator) => {
+ c.push(collaborator);
+ },
+ Err(_) => {
+ context.report.conversion_error(
+ &format!("COSE protected header collaborator index {ids}"),
+ &format!("{collaborator:?}"),
+ "Expected a CBOR String",
+ &format!(
+ "{COSE_DECODING_CONTEXT}, converting collaborator to String",
+ ),
+ );
+ },
+ }
+ }
+ metadata.collabs = c;
+ } else {
+ context.report.conversion_error(
+ "CBOR COSE protected header collaborators",
+ &format!("{cbor_doc_collabs:?}"),
+ "Expected a CBOR Array",
+ &format!("{COSE_DECODING_CONTEXT}, converting collaborators to Array",),
+ );
+ };
+ }
+
metadata
}
}
@@ -216,12 +434,19 @@ impl InnerMetadata {
impl Display for Metadata {
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> {
writeln!(f, "Metadata {{")?;
- writeln!(f, " type: {:?},", self.0.doc_type)?;
- writeln!(f, " id: {:?},", self.0.id)?;
- writeln!(f, " ver: {:?},", self.0.ver)?;
- writeln!(f, " content_type: {:?}", self.0.content_type)?;
- writeln!(f, " content_encoding: {:?}", self.0.content_encoding)?;
- writeln!(f, " additional_fields: {:?},", self.0.extra)?;
+ writeln!(f, " type: {:?},", self.doc_type().ok())?;
+ writeln!(f, " id: {:?},", self.doc_id().ok())?;
+ writeln!(f, " ver: {:?},", self.doc_ver().ok())?;
+ writeln!(f, " content_type: {:?},", self.content_type().ok())?;
+ writeln!(f, " content_encoding: {:?},", self.content_encoding())?;
+ writeln!(f, " additional_fields: {{")?;
+ writeln!(f, " ref: {:?}", self.doc_ref())?;
+ writeln!(f, " template: {:?},", self.template())?;
+ writeln!(f, " reply: {:?},", self.reply())?;
+ writeln!(f, " section: {:?},", self.section())?;
+ writeln!(f, " collabs: {:?},", self.collabs())?;
+ writeln!(f, " parameters: {:?},", self.parameters())?;
+ writeln!(f, " }},")?;
writeln!(f, "}}")
}
}
@@ -241,10 +466,7 @@ impl TryFrom<&Metadata> for coset::Header {
}
builder = builder
- .text_value(
- TYPE_KEY.to_string(),
- Value::try_from(CborUuidV4(meta.doc_type()?))?,
- )
+ .text_value(TYPE_KEY.to_string(), meta.doc_type()?.to_value())
.text_value(
ID_KEY.to_string(),
Value::try_from(CborUuidV7(meta.doc_id()?))?,
@@ -254,8 +476,168 @@ impl TryFrom<&Metadata> for coset::Header {
Value::try_from(CborUuidV7(meta.doc_ver()?))?,
);
- builder = meta.0.extra.fill_cose_header_fields(builder)?;
+ if let Some(doc_ref) = meta.doc_ref() {
+ builder = builder.text_value(REF_KEY.to_string(), Value::try_from(doc_ref)?);
+ }
+ if let Some(template) = meta.template() {
+ builder = builder.text_value(TEMPLATE_KEY.to_string(), Value::try_from(template)?);
+ }
+ if let Some(reply) = meta.reply() {
+ builder = builder.text_value(REPLY_KEY.to_string(), Value::try_from(reply)?);
+ }
+
+ if let Some(section) = meta.section() {
+ builder = builder.text_value(SECTION_KEY.to_string(), Value::from(section.clone()));
+ }
+
+ if !meta.collabs().is_empty() {
+ builder = builder.text_value(
+ COLLABS_KEY.to_string(),
+ Value::Array(meta.collabs().iter().cloned().map(Value::Text).collect()),
+ );
+ }
+
+ if let Some(parameters) = meta.parameters() {
+ builder = builder.text_value(PARAMETERS_KEY.to_string(), Value::try_from(parameters)?);
+ }
Ok(builder.build())
}
}
+
+/// [`Metadata`] encoding context for the [`minicbor::Encode`] implementation.
+pub(crate) struct MetadataEncodeContext {
+ /// Used by some fields' encoding implementations.
+ pub uuid_context: catalyst_types::uuid::CborContext,
+ /// Used by some fields' encoding implementations.
+ pub report: ProblemReport,
+}
+
+impl minicbor::Encode for Metadata {
+ /// Encode as a CBOR map.
+ ///
+ /// Note that to put it in an [RFC 8152] protected header.
+ /// The header must be then encoded as a binary string.
+ ///
+ /// Also note that this won't check the presence of the required fields,
+ /// so the checks must be done elsewhere.
+ ///
+ /// [RFC 8152]: https://datatracker.ietf.org/doc/html/rfc8152#autoid-8
+ #[allow(
+ clippy::cast_possible_truncation,
+ reason = "There can't be enough unique fields to overflow `u64`."
+ )]
+ fn encode(
+ &self, e: &mut minicbor::Encoder, ctx: &mut MetadataEncodeContext,
+ ) -> Result<(), minicbor::encode::Error> {
+ e.map(self.0.len() as u64)?;
+ self.0
+ .values()
+ .try_fold(e, |e, field| e.encode_with(field, ctx))?
+ .ok()
+ }
+}
+
+/// [`Metadata`] decoding context for the [`minicbor::Decode`] implementation.
+pub(crate) struct MetadataDecodeContext {
+ /// Used by some fields' decoding implementations.
+ pub uuid_context: catalyst_types::uuid::CborContext,
+ /// Used by some fields' decoding implementations.
+ pub compatibility_policy: crate::CompatibilityPolicy,
+ /// Used by some fields' decoding implementations.
+ pub report: ProblemReport,
+}
+
+impl MetadataDecodeContext {
+ /// [`DocType`] decoding context.
+ fn doc_type_context(&mut self) -> crate::decode_context::DecodeContext {
+ crate::decode_context::DecodeContext {
+ compatibility_policy: self.compatibility_policy,
+ report: &mut self.report,
+ }
+ }
+}
+
+/// An error that's been reported, but doesn't affect the further decoding.
+/// [`minicbor::Decoder`] should be assumed to be in a correct state and advanced towards
+/// the next item.
+///
+/// The wrapped error can be returned up the call stack.
+#[derive(Debug, thiserror::Error)]
+#[error(transparent)]
+pub struct TransientDecodeError(pub minicbor::decode::Error);
+
+/// Creates a [`TransientDecodeError`] and wraps it in a
+/// [`minicbor::decode::Error::custom`].
+fn custom_transient_decode_error(
+ message: &str, position: Option,
+) -> minicbor::decode::Error {
+ let mut inner = minicbor::decode::Error::message(message);
+ if let Some(pos) = position {
+ inner = inner.at(pos);
+ }
+ minicbor::decode::Error::custom(TransientDecodeError(inner))
+}
+
+impl minicbor::Decode<'_, MetadataDecodeContext> for Metadata {
+ /// Decode from a CBOR map.
+ ///
+ /// Note that this won't decode an [RFC 8152] protected header as is.
+ /// The header must be first decoded as a binary string.
+ ///
+ /// Also note that this won't check the absence of the required fields,
+ /// so the checks must be done elsewhere.
+ ///
+ /// [RFC 8152]: https://datatracker.ietf.org/doc/html/rfc8152#autoid-8
+ fn decode(
+ d: &mut Decoder<'_>, ctx: &mut MetadataDecodeContext,
+ ) -> Result {
+ const REPORT_CONTEXT: &str = "Metadata decoding";
+
+ let Some(len) = d.map()? else {
+ return Err(minicbor::decode::Error::message(
+ "Indefinite map is not supported",
+ ));
+ };
+
+ // TODO: verify key order.
+ // TODO: use helpers from once it's merged.
+
+ let mut metadata_map = BTreeMap::new();
+ let mut first_err = None;
+
+ // This will return an error on the end of input.
+ for _ in 0..len {
+ let entry_pos = d.position();
+ match d.decode_with::<_, SupportedField>(ctx) {
+ Ok(field) => {
+ let label = field.discriminant();
+ let entry = metadata_map.entry(label);
+ if let btree_map::Entry::Vacant(entry) = entry {
+ entry.insert(field);
+ } else {
+ ctx.report.duplicate_field(
+ &label.to_string(),
+ "Duplicate metadata fields are not allowed",
+ REPORT_CONTEXT,
+ );
+ first_err.get_or_insert(custom_transient_decode_error(
+ "Duplicate fields",
+ Some(entry_pos),
+ ));
+ }
+ },
+ Err(err)
+ if err
+ .source()
+ .is_some_and(::is::) =>
+ {
+ first_err.get_or_insert(err);
+ },
+ Err(err) => return Err(err),
+ }
+ }
+
+ first_err.map_or(Ok(Self(metadata_map)), Err)
+ }
+}
diff --git a/rust/signed_doc/src/metadata/supported_field.rs b/rust/signed_doc/src/metadata/supported_field.rs
new file mode 100644
index 0000000000..9ce4ecb122
--- /dev/null
+++ b/rust/signed_doc/src/metadata/supported_field.rs
@@ -0,0 +1,285 @@
+//! Catalyst Signed Document unified metadata field.
+
+use std::fmt::{self, Display};
+#[cfg(test)]
+use std::{cmp, convert::Infallible};
+
+use catalyst_types::uuid::UuidV7;
+use strum::{EnumDiscriminants, EnumTryAs, IntoDiscriminant as _};
+
+use crate::{
+ metadata::{custom_transient_decode_error, MetadataDecodeContext, MetadataEncodeContext},
+ ContentEncoding, ContentType, DocType, DocumentRef, Section,
+};
+
+/// COSE label. May be either a signed integer or a string.
+#[derive(Copy, Clone, Eq, PartialEq)]
+enum Label<'a> {
+ /// Integer label.
+ ///
+ /// Note that COSE isn't strictly limited to 8 bits for a label, but in practice it
+ /// fits.
+ ///
+ /// If for any reason wider bounds would be necessary,
+ /// then additional variants could be added to the [`Label`].
+ U8(u8),
+ /// Text label.
+ Str(&'a str),
+}
+
+impl minicbor::Encode for Label<'_> {
+ fn encode(
+ &self, e: &mut minicbor::Encoder, _: &mut C,
+ ) -> Result<(), minicbor::encode::Error> {
+ match self {
+ &Label::U8(u) => e.u8(u),
+ Label::Str(s) => e.str(s),
+ }?
+ .ok()
+ }
+}
+
+impl<'a, C> minicbor::Decode<'a, C> for Label<'a> {
+ fn decode(d: &mut minicbor::Decoder<'a>, _: &mut C) -> Result {
+ match d.datatype()? {
+ minicbor::data::Type::U8 => d.u8().map(Self::U8),
+ minicbor::data::Type::String => d.str().map(Self::Str),
+ _ => {
+ Err(minicbor::decode::Error::message(
+ "Datatype is neither 8bit signed integer nor text",
+ )
+ .at(d.position()))
+ },
+ }
+ }
+}
+
+#[cfg(test)]
+impl Label<'_> {
+ /// Compare by [RFC 8949 section 4.2.1] specification.
+ ///
+ /// [RFC 8949 section 4.2.1]: https://www.rfc-editor.org/rfc/rfc8949.html#section-4.2.1
+ fn rfc8949_cmp(
+ &self, other: &Self,
+ ) -> Result> {
+ let lhs = minicbor::to_vec(self)?;
+ let rhs = minicbor::to_vec(other)?;
+ let ord = lhs.len().cmp(&rhs.len()).then_with(|| lhs.cmp(&rhs));
+ Ok(ord)
+ }
+}
+
+impl Display for Label<'_> {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Label::U8(u) => write!(f, "{u}"),
+ Label::Str(s) => f.write_str(s),
+ }
+ }
+}
+
+/// Catalyst Signed Document metadata field.
+/// Fields are assigned discriminants based on deterministic ordering (see [RFC 8949
+/// section 4.2.1]).
+///
+/// Note that [`PartialEq`] implementation compares both keys and values.
+///
+/// [RFC 8949 section 4.2.1]: https://www.rfc-editor.org/rfc/rfc8949.html#section-4.2.1
+#[derive(Clone, Debug, PartialEq, EnumDiscriminants, EnumTryAs)]
+#[strum_discriminants(
+ name(SupportedLabel),
+ derive(Ord, PartialOrd),
+ cfg_attr(test, derive(strum::VariantArray))
+)]
+#[non_exhaustive]
+#[repr(usize)]
+pub enum SupportedField {
+ /// `content-type` field. In COSE it's represented as the signed integer `3` (see [RFC
+ /// 8949 section 3.1]).
+ ///
+ /// [RFC 8949 section 3.1]: https://datatracker.ietf.org/doc/html/rfc8152#section-3.1
+ ContentType(ContentType) = 0,
+ /// `id` field.
+ Id(UuidV7) = 1,
+ /// `ref` field.
+ Ref(DocumentRef) = 2,
+ /// `ver` field.
+ Ver(UuidV7) = 3,
+ /// `type` field.
+ Type(DocType) = 4,
+ /// `reply` field.
+ Reply(DocumentRef) = 5,
+ /// `collabs` field.
+ Collabs(Vec) = 7,
+ /// `section` field.
+ Section(Section) = 8,
+ /// `template` field.
+ Template(DocumentRef) = 9,
+ /// `parameters` field.
+ Parameters(DocumentRef) = 10,
+ /// `Content-Encoding` field.
+ ContentEncoding(ContentEncoding) = 11,
+}
+
+impl SupportedLabel {
+ /// Try to convert from an arbitrary COSE [`Label`].
+ fn from_cose(label: Label<'_>) -> Option {
+ match label {
+ Label::U8(3) => Some(Self::ContentType),
+ Label::Str("id") => Some(Self::Id),
+ Label::Str("ref") => Some(Self::Ref),
+ Label::Str("ver") => Some(Self::Ver),
+ Label::Str("type") => Some(Self::Type),
+ Label::Str("reply") => Some(Self::Reply),
+ Label::Str("collabs") => Some(Self::Collabs),
+ Label::Str("section") => Some(Self::Section),
+ Label::Str("template") => Some(Self::Template),
+ Label::Str("parameters" | "brand_id" | "campaign_id" | "category_id") => {
+ Some(Self::Parameters)
+ },
+ Label::Str("Content-Encoding") => Some(Self::ContentEncoding),
+ _ => None,
+ }
+ }
+
+ /// Convert to the corresponding COSE [`Label`].
+ fn to_cose(self) -> Label<'static> {
+ match self {
+ Self::ContentType => Label::U8(3),
+ Self::Id => Label::Str("id"),
+ Self::Ref => Label::Str("ref"),
+ Self::Ver => Label::Str("ver"),
+ Self::Type => Label::Str("type"),
+ Self::Reply => Label::Str("reply"),
+ Self::Collabs => Label::Str("collabs"),
+ Self::Section => Label::Str("section"),
+ Self::Template => Label::Str("template"),
+ Self::Parameters => Label::Str("parameters"),
+ Self::ContentEncoding => Label::Str("Content-Encoding"),
+ }
+ }
+}
+
+impl Display for SupportedLabel {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ Display::fmt(&self.to_cose(), f)
+ }
+}
+
+impl minicbor::Decode<'_, MetadataDecodeContext> for SupportedField {
+ #[allow(clippy::todo, reason = "Not migrated to `minicbor` yet.")]
+ fn decode(
+ d: &mut minicbor::Decoder<'_>, ctx: &mut MetadataDecodeContext,
+ ) -> Result {
+ const REPORT_CONTEXT: &str = "Metadata field decoding";
+
+ let label_pos = d.position();
+ let label = Label::decode(d, &mut ())?;
+ let Some(key) = SupportedLabel::from_cose(label) else {
+ let value_start = d.position();
+ d.skip()?;
+ let value_end = d.position();
+ // Since the high level type isn't know, the value CBOR is tokenized and reported as
+ // such.
+ let value = minicbor::decode::Tokenizer::new(
+ d.input().get(value_start..value_end).unwrap_or_default(),
+ )
+ .to_string();
+ ctx.report
+ .unknown_field(&label.to_string(), &value, REPORT_CONTEXT);
+ return Err(custom_transient_decode_error(
+ "Not a supported key",
+ Some(label_pos),
+ ));
+ };
+
+ let field = match key {
+ SupportedLabel::ContentType => todo!(),
+ SupportedLabel::Id => d.decode_with(&mut ctx.uuid_context).map(Self::Id),
+ SupportedLabel::Ref => todo!(),
+ SupportedLabel::Ver => d.decode_with(&mut ctx.uuid_context).map(Self::Ver),
+ SupportedLabel::Type => d.decode_with(&mut ctx.doc_type_context()).map(Self::Type),
+ SupportedLabel::Reply => todo!(),
+ SupportedLabel::Collabs => todo!(),
+ SupportedLabel::Section => todo!(),
+ SupportedLabel::Template => todo!(),
+ SupportedLabel::Parameters => todo!(),
+ SupportedLabel::ContentEncoding => todo!(),
+ }?;
+
+ Ok(field)
+ }
+}
+
+impl minicbor::Encode for SupportedField {
+ #[allow(clippy::todo, reason = "Not migrated to `minicbor` yet.")]
+ fn encode(
+ &self, e: &mut minicbor::Encoder, ctx: &mut MetadataEncodeContext,
+ ) -> Result<(), minicbor::encode::Error> {
+ let key = self.discriminant().to_cose();
+ e.encode(key)?;
+
+ match self {
+ SupportedField::ContentType(_content_type) => todo!(),
+ SupportedField::Id(uuid_v7) | SupportedField::Ver(uuid_v7) => {
+ e.encode_with(uuid_v7, &mut ctx.uuid_context)?
+ },
+ SupportedField::Ref(_document_ref)
+ | SupportedField::Reply(_document_ref)
+ | SupportedField::Template(_document_ref)
+ | SupportedField::Parameters(_document_ref) => todo!(),
+ SupportedField::Type(doc_type) => e.encode_with(doc_type, &mut ctx.report)?,
+ SupportedField::Collabs(_items) => todo!(),
+ SupportedField::Section(_section) => todo!(),
+ SupportedField::ContentEncoding(_content_encoding) => todo!(),
+ }
+ .ok()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use strum::VariantArray as _;
+
+ use super::*;
+
+ /// Checks that [`Label::rfc8949_cmp`] ordering is compliant with the RFC.
+ #[test]
+ fn label_rfc8949_cmp() {
+ assert_eq!(
+ Label::Str("a").rfc8949_cmp(&Label::Str("a")).unwrap(),
+ cmp::Ordering::Equal
+ );
+ assert_eq!(
+ Label::Str("a").rfc8949_cmp(&Label::Str("aa")).unwrap(),
+ cmp::Ordering::Less
+ );
+ assert_eq!(
+ Label::Str("a").rfc8949_cmp(&Label::Str("b")).unwrap(),
+ cmp::Ordering::Less
+ );
+ assert_eq!(
+ Label::Str("aa").rfc8949_cmp(&Label::Str("b")).unwrap(),
+ cmp::Ordering::Greater
+ );
+ assert_eq!(
+ Label::U8(3).rfc8949_cmp(&Label::Str("id")).unwrap(),
+ cmp::Ordering::Less
+ );
+ }
+
+ /// Checks that [`SupportedLabel`] enum integer values correspond to
+ /// [`Label::rfc8949_cmp`] ordering.
+ #[test]
+ fn supported_label_rfc8949_ord() {
+ let mut enum_ord = SupportedLabel::VARIANTS.to_vec();
+ // Sorting by the Rust enum representation.
+ enum_ord.sort_unstable();
+
+ let mut cose_ord = SupportedLabel::VARIANTS.to_vec();
+ // Sorting by the corresponding COSE labels.
+ cose_ord.sort_unstable_by(|lhs, rhs| lhs.to_cose().rfc8949_cmp(&rhs.to_cose()).unwrap());
+
+ assert_eq!(enum_ord, cose_ord);
+ }
+}
diff --git a/rust/signed_doc/src/validator/mod.rs b/rust/signed_doc/src/validator/mod.rs
index 0c755bdcb5..da1b4604d2 100644
--- a/rust/signed_doc/src/validator/mod.rs
+++ b/rust/signed_doc/src/validator/mod.rs
@@ -5,7 +5,6 @@ pub(crate) mod utils;
use std::{
collections::HashMap,
- fmt,
sync::LazyLock,
time::{Duration, SystemTime},
};
@@ -14,7 +13,6 @@ use anyhow::Context;
use catalyst_types::{
catalyst_id::{role_index::RoleId, CatalystId},
problem_report::ProblemReport,
- uuid::{Uuid, UuidV4},
};
use coset::{CoseSign, CoseSignature};
use rules::{
@@ -24,28 +22,35 @@ use rules::{
use crate::{
doc_types::{
- CATEGORY_DOCUMENT_UUID_TYPE, COMMENT_DOCUMENT_UUID_TYPE, COMMENT_TEMPLATE_UUID_TYPE,
- PROPOSAL_ACTION_DOCUMENT_UUID_TYPE, PROPOSAL_DOCUMENT_UUID_TYPE,
- PROPOSAL_TEMPLATE_UUID_TYPE,
+ deprecated::{
+ CATEGORY_DOCUMENT_UUID_TYPE, COMMENT_TEMPLATE_UUID_TYPE, PROPOSAL_TEMPLATE_UUID_TYPE,
+ },
+ COMMENT_UUID_TYPE, PROPOSAL_ACTION_DOC, PROPOSAL_COMMENT_DOC, PROPOSAL_DOC_TYPE,
+ PROPOSAL_UUID_TYPE,
},
+ metadata::DocType,
providers::{CatalystSignedDocumentProvider, VerifyingKeyProvider},
CatalystSignedDocument, ContentEncoding, ContentType,
};
/// A table representing a full set or validation rules per document id.
-static DOCUMENT_RULES: LazyLock> = LazyLock::new(document_rules_init);
+static DOCUMENT_RULES: LazyLock> = LazyLock::new(document_rules_init);
-/// Returns an [`UuidV4`] from the provided argument, panicking if the argument is
-/// invalid.
+/// Returns an `DocType` from the provided argument.
+/// Reduce redundant conversion.
+/// This function should be used for hardcoded values, panic if conversion fail.
#[allow(clippy::expect_used)]
-fn expect_uuidv4(t: T) -> UuidV4
-where T: TryInto {
- t.try_into().expect("Must be a valid UUID V4")
+pub(crate) fn expect_doc_type(t: T) -> DocType
+where
+ T: TryInto,
+ T::Error: std::fmt::Debug,
+{
+ t.try_into().expect("Failed to convert to DocType")
}
/// `DOCUMENT_RULES` initialization function
#[allow(clippy::expect_used)]
-fn document_rules_init() -> HashMap {
+fn document_rules_init() -> HashMap {
let mut document_rules_map = HashMap::new();
let proposal_document_rules = Rules {
@@ -57,10 +62,10 @@ fn document_rules_init() -> HashMap {
optional: false,
},
content: ContentRule::Templated {
- exp_template_type: expect_uuidv4(PROPOSAL_TEMPLATE_UUID_TYPE),
+ exp_template_type: expect_doc_type(PROPOSAL_TEMPLATE_UUID_TYPE),
},
parameters: ParametersRule::Specified {
- exp_parameters_type: expect_uuidv4(CATEGORY_DOCUMENT_UUID_TYPE),
+ exp_parameters_type: expect_doc_type(CATEGORY_DOCUMENT_UUID_TYPE),
optional: true,
},
doc_ref: RefRule::NotSpecified,
@@ -71,7 +76,7 @@ fn document_rules_init() -> HashMap {
},
};
- document_rules_map.insert(PROPOSAL_DOCUMENT_UUID_TYPE, proposal_document_rules);
+ document_rules_map.insert(PROPOSAL_DOC_TYPE.clone(), proposal_document_rules);
let comment_document_rules = Rules {
content_type: ContentTypeRule {
@@ -82,14 +87,14 @@ fn document_rules_init() -> HashMap {
optional: false,
},
content: ContentRule::Templated {
- exp_template_type: expect_uuidv4(COMMENT_TEMPLATE_UUID_TYPE),
+ exp_template_type: expect_doc_type(COMMENT_TEMPLATE_UUID_TYPE),
},
doc_ref: RefRule::Specified {
- exp_ref_type: expect_uuidv4(PROPOSAL_DOCUMENT_UUID_TYPE),
+ exp_ref_type: expect_doc_type(PROPOSAL_UUID_TYPE),
optional: false,
},
reply: ReplyRule::Specified {
- exp_reply_type: expect_uuidv4(COMMENT_DOCUMENT_UUID_TYPE),
+ exp_reply_type: expect_doc_type(COMMENT_UUID_TYPE),
optional: true,
},
section: SectionRule::Specified { optional: true },
@@ -98,7 +103,7 @@ fn document_rules_init() -> HashMap {
exp: &[RoleId::Role0],
},
};
- document_rules_map.insert(COMMENT_DOCUMENT_UUID_TYPE, comment_document_rules);
+ document_rules_map.insert(PROPOSAL_COMMENT_DOC.clone(), comment_document_rules);
let proposal_action_json_schema = jsonschema::options()
.with_draft(jsonschema::Draft::Draft7)
@@ -119,11 +124,11 @@ fn document_rules_init() -> HashMap {
},
content: ContentRule::Static(ContentSchema::Json(proposal_action_json_schema)),
parameters: ParametersRule::Specified {
- exp_parameters_type: expect_uuidv4(CATEGORY_DOCUMENT_UUID_TYPE),
+ exp_parameters_type: expect_doc_type(CATEGORY_DOCUMENT_UUID_TYPE),
optional: true,
},
doc_ref: RefRule::Specified {
- exp_ref_type: expect_uuidv4(PROPOSAL_DOCUMENT_UUID_TYPE),
+ exp_ref_type: expect_doc_type(PROPOSAL_UUID_TYPE),
optional: false,
},
reply: ReplyRule::NotSpecified,
@@ -134,7 +139,7 @@ fn document_rules_init() -> HashMap {
};
document_rules_map.insert(
- PROPOSAL_ACTION_DOCUMENT_UUID_TYPE,
+ PROPOSAL_ACTION_DOC.clone(),
proposal_submission_action_rules,
);
@@ -164,7 +169,7 @@ where Provider: CatalystSignedDocumentProvider {
return Ok(false);
}
- let Some(rules) = DOCUMENT_RULES.get(&doc_type.uuid()) else {
+ let Some(rules) = DOCUMENT_RULES.get(doc_type) else {
doc.report().invalid_value(
"`type`",
&doc.doc_type()?.to_string(),
diff --git a/rust/signed_doc/src/validator/rules/content_type.rs b/rust/signed_doc/src/validator/rules/content_type.rs
index 26aa702fa7..f7852e0c62 100644
--- a/rust/signed_doc/src/validator/rules/content_type.rs
+++ b/rust/signed_doc/src/validator/rules/content_type.rs
@@ -36,7 +36,7 @@ impl ContentTypeRule {
);
return Ok(false);
};
- if content_type.validate(content).is_err() {
+ if self.validate(content).is_err() {
doc.report().invalid_value(
"payload",
&hex::encode(content),
@@ -48,6 +48,27 @@ impl ContentTypeRule {
Ok(true)
}
+
+ /// Validates the provided `content` bytes to be a defined `ContentType`.
+ fn validate(&self, content: &[u8]) -> anyhow::Result<()> {
+ match self.exp {
+ ContentType::Json => {
+ if let Err(e) = serde_json::from_slice::<&serde_json::value::RawValue>(content) {
+ anyhow::bail!("Invalid {} content: {e}", self.exp)
+ }
+ },
+ ContentType::Cbor => {
+ let mut decoder = minicbor::Decoder::new(content);
+
+ decoder.skip()?;
+
+ if decoder.position() != content.len() {
+ anyhow::bail!("Unused bytes remain in the input after decoding")
+ }
+ },
+ }
+ Ok(())
+ }
}
#[cfg(test)]
@@ -56,39 +77,120 @@ mod tests {
use crate::Builder;
#[tokio::test]
- async fn content_type_rule_test() {
- let content_type = ContentType::Json;
+ async fn cbor_with_trailing_bytes_test() {
+ // valid cbor: {1: 2} but with trailing 0xff
+ let mut buf = Vec::new();
+ let mut enc = minicbor::Encoder::new(&mut buf);
+ enc.map(1).unwrap().u8(1).unwrap().u8(2).unwrap();
+ buf.push(0xFF); // extra byte
+
+ let cbor_rule = ContentTypeRule {
+ exp: ContentType::Cbor,
+ };
+
+ let doc = Builder::new()
+ .with_json_metadata(serde_json::json!({ "content-type": cbor_rule.exp.to_string() }))
+ .unwrap()
+ .with_decoded_content(buf)
+ .build();
+
+ assert!(matches!(cbor_rule.check(&doc).await, Ok(false)));
+ }
+
+ #[tokio::test]
+ async fn malformed_cbor_bytes_test() {
+ // 0xa2 means a map with 2 key-value pairs, but we only give 1 key
+ let invalid_bytes = &[0xA2, 0x01];
+
+ let cbor_rule = ContentTypeRule {
+ exp: ContentType::Cbor,
+ };
+
+ let doc = Builder::new()
+ .with_json_metadata(serde_json::json!({ "content-type": cbor_rule.exp.to_string() }))
+ .unwrap()
+ .with_decoded_content(invalid_bytes.into())
+ .build();
+
+ assert!(matches!(cbor_rule.check(&doc).await, Ok(false)));
+ }
- let rule = ContentTypeRule { exp: content_type };
+ #[tokio::test]
+ async fn content_type_cbor_rule_test() {
+ let cbor_rule = ContentTypeRule {
+ exp: ContentType::Cbor,
+ };
+ // with json bytes
let doc = Builder::new()
- .with_json_metadata(serde_json::json!({"content-type": content_type.to_string() }))
+ .with_json_metadata(serde_json::json!({"content-type": cbor_rule.exp.to_string() }))
.unwrap()
.with_decoded_content(serde_json::to_vec(&serde_json::json!({})).unwrap())
.build();
- assert!(rule.check(&doc).await.unwrap());
+ assert!(matches!(cbor_rule.check(&doc).await, Ok(false)));
+
+ // with cbor bytes
+ let doc = Builder::new()
+ .with_json_metadata(serde_json::json!({"content-type": cbor_rule.exp.to_string() }))
+ .unwrap()
+ .with_decoded_content(minicbor::to_vec(minicbor::data::Token::Null).unwrap())
+ .build();
+ assert!(matches!(cbor_rule.check(&doc).await, Ok(true)));
+ // without content
let doc = Builder::new()
- .with_json_metadata(serde_json::json!({"content-type": ContentType::Cbor.to_string() }))
+ .with_json_metadata(serde_json::json!({"content-type": cbor_rule.exp.to_string() }))
+ .unwrap()
+ .build();
+ assert!(matches!(cbor_rule.check(&doc).await, Ok(false)));
+
+ // with empty content
+ let doc = Builder::new()
+ .with_json_metadata(serde_json::json!({"content-type": cbor_rule.exp.to_string() }))
+ .unwrap()
+ .with_decoded_content(vec![])
+ .build();
+ assert!(matches!(cbor_rule.check(&doc).await, Ok(false)));
+ }
+
+ #[tokio::test]
+ async fn content_type_json_rule_test() {
+ let json_rule = ContentTypeRule {
+ exp: ContentType::Json,
+ };
+
+ // with json bytes
+ let doc = Builder::new()
+ .with_json_metadata(serde_json::json!({"content-type": json_rule.exp.to_string() }))
.unwrap()
.with_decoded_content(serde_json::to_vec(&serde_json::json!({})).unwrap())
.build();
- assert!(!rule.check(&doc).await.unwrap());
+ assert!(matches!(json_rule.check(&doc).await, Ok(true)));
+
+ // with cbor bytes
+ let doc = Builder::new()
+ .with_json_metadata(serde_json::json!({"content-type": json_rule.exp.to_string() }))
+ .unwrap()
+ .with_decoded_content(minicbor::to_vec(minicbor::data::Token::Null).unwrap())
+ .build();
+ assert!(matches!(json_rule.check(&doc).await, Ok(false)));
+ // without content
let doc = Builder::new()
- .with_json_metadata(serde_json::json!({"content-type": content_type.to_string() }))
+ .with_json_metadata(serde_json::json!({"content-type": json_rule.exp.to_string() }))
.unwrap()
.build();
- assert!(!rule.check(&doc).await.unwrap());
+ assert!(matches!(json_rule.check(&doc).await, Ok(false)));
+ // with empty content
let doc = Builder::new()
- .with_json_metadata(serde_json::json!({"content-type": content_type.to_string() }))
+ .with_json_metadata(serde_json::json!({"content-type": json_rule.exp.to_string() }))
.unwrap()
.with_decoded_content(vec![])
.build();
- assert!(!rule.check(&doc).await.unwrap());
+ assert!(matches!(json_rule.check(&doc).await, Ok(false)));
let doc = Builder::new().build();
- assert!(!rule.check(&doc).await.unwrap());
+ assert!(matches!(json_rule.check(&doc).await, Ok(false)));
}
}
diff --git a/rust/signed_doc/src/validator/rules/doc_ref.rs b/rust/signed_doc/src/validator/rules/doc_ref.rs
index 53fec6825f..977893b061 100644
--- a/rust/signed_doc/src/validator/rules/doc_ref.rs
+++ b/rust/signed_doc/src/validator/rules/doc_ref.rs
@@ -1,13 +1,10 @@
//! `ref` rule type impl.
-use catalyst_types::{
- problem_report::ProblemReport,
- uuid::{Uuid, UuidV4},
-};
+use catalyst_types::problem_report::ProblemReport;
use crate::{
- providers::CatalystSignedDocumentProvider, validator::utils::validate_provided_doc,
- CatalystSignedDocument,
+ metadata::DocType, providers::CatalystSignedDocumentProvider,
+ validator::utils::validate_provided_doc, CatalystSignedDocument,
};
/// `ref` field validation rule
@@ -16,7 +13,7 @@ pub(crate) enum RefRule {
/// Is 'ref' specified
Specified {
/// expected `type` field of the referenced doc
- exp_ref_type: UuidV4,
+ exp_ref_type: DocType,
/// optional flag for the `ref` field
optional: bool,
},
@@ -36,7 +33,7 @@ impl RefRule {
{
if let Some(doc_ref) = doc.doc_meta().doc_ref() {
let ref_validator = |ref_doc: CatalystSignedDocument| {
- referenced_doc_check(&ref_doc, exp_ref_type.uuid(), "ref", doc.report())
+ referenced_doc_check(&ref_doc, exp_ref_type, "ref", doc.report())
};
return validate_provided_doc(&doc_ref, provider, doc.report(), ref_validator)
.await;
@@ -63,13 +60,14 @@ impl RefRule {
/// A generic implementation of the referenced document validation.
pub(crate) fn referenced_doc_check(
- ref_doc: &CatalystSignedDocument, exp_ref_type: Uuid, field_name: &str, report: &ProblemReport,
+ ref_doc: &CatalystSignedDocument, exp_ref_type: &DocType, field_name: &str,
+ report: &ProblemReport,
) -> bool {
let Ok(ref_doc_type) = ref_doc.doc_type() else {
report.missing_field("type", "Referenced document must have type field");
return false;
};
- if ref_doc_type.uuid() != exp_ref_type {
+ if ref_doc_type != exp_ref_type {
report.invalid_value(
field_name,
ref_doc_type.to_string().as_str(),
@@ -83,7 +81,7 @@ pub(crate) fn referenced_doc_check(
#[cfg(test)]
mod tests {
- use catalyst_types::uuid::UuidV7;
+ use catalyst_types::uuid::{UuidV4, UuidV7};
use super::*;
use crate::{providers::tests::TestCatalystSignedDocumentProvider, Builder};
@@ -137,7 +135,7 @@ mod tests {
// all correct
let rule = RefRule::Specified {
- exp_ref_type,
+ exp_ref_type: exp_ref_type.into(),
optional: false,
};
let doc = Builder::new()
@@ -150,7 +148,7 @@ mod tests {
// all correct, `ref` field is missing, but its optional
let rule = RefRule::Specified {
- exp_ref_type,
+ exp_ref_type: exp_ref_type.into(),
optional: true,
};
let doc = Builder::new().build();
@@ -158,7 +156,7 @@ mod tests {
// missing `ref` field, but its required
let rule = RefRule::Specified {
- exp_ref_type,
+ exp_ref_type: exp_ref_type.into(),
optional: false,
};
let doc = Builder::new().build();
diff --git a/rust/signed_doc/src/validator/rules/parameters.rs b/rust/signed_doc/src/validator/rules/parameters.rs
index 290d158439..41ad404df0 100644
--- a/rust/signed_doc/src/validator/rules/parameters.rs
+++ b/rust/signed_doc/src/validator/rules/parameters.rs
@@ -1,11 +1,9 @@
//! `parameters` rule type impl.
-use catalyst_types::uuid::UuidV4;
-
use super::doc_ref::referenced_doc_check;
use crate::{
- providers::CatalystSignedDocumentProvider, validator::utils::validate_provided_doc,
- CatalystSignedDocument,
+ metadata::DocType, providers::CatalystSignedDocumentProvider,
+ validator::utils::validate_provided_doc, CatalystSignedDocument,
};
/// `parameters` field validation rule
@@ -14,7 +12,7 @@ pub(crate) enum ParametersRule {
/// Is `parameters` specified
Specified {
/// expected `type` field of the parameter doc
- exp_parameters_type: UuidV4,
+ exp_parameters_type: DocType,
/// optional flag for the `parameters` field
optional: bool,
},
@@ -37,7 +35,7 @@ impl ParametersRule {
let parameters_validator = |replied_doc: CatalystSignedDocument| {
referenced_doc_check(
&replied_doc,
- exp_parameters_type.uuid(),
+ exp_parameters_type,
"parameters",
doc.report(),
)
@@ -126,7 +124,7 @@ mod tests {
// all correct
let rule = ParametersRule::Specified {
- exp_parameters_type,
+ exp_parameters_type: exp_parameters_type.into(),
optional: false,
};
let doc = Builder::new()
@@ -139,7 +137,7 @@ mod tests {
// all correct, `parameters` field is missing, but its optional
let rule = ParametersRule::Specified {
- exp_parameters_type,
+ exp_parameters_type: exp_parameters_type.into(),
optional: true,
};
let doc = Builder::new().build();
@@ -147,7 +145,7 @@ mod tests {
// missing `parameters` field, but its required
let rule = ParametersRule::Specified {
- exp_parameters_type,
+ exp_parameters_type: exp_parameters_type.into(),
optional: false,
};
let doc = Builder::new().build();
diff --git a/rust/signed_doc/src/validator/rules/reply.rs b/rust/signed_doc/src/validator/rules/reply.rs
index 5ac256667d..09b48ea28d 100644
--- a/rust/signed_doc/src/validator/rules/reply.rs
+++ b/rust/signed_doc/src/validator/rules/reply.rs
@@ -1,11 +1,9 @@
//! `reply` rule type impl.
-use catalyst_types::uuid::UuidV4;
-
use super::doc_ref::referenced_doc_check;
use crate::{
- providers::CatalystSignedDocumentProvider, validator::utils::validate_provided_doc,
- CatalystSignedDocument,
+ metadata::DocType, providers::CatalystSignedDocumentProvider,
+ validator::utils::validate_provided_doc, CatalystSignedDocument,
};
/// `reply` field validation rule
@@ -14,7 +12,7 @@ pub(crate) enum ReplyRule {
/// Is 'reply' specified
Specified {
/// expected `type` field of the replied doc
- exp_reply_type: UuidV4,
+ exp_reply_type: DocType,
/// optional flag for the `ref` field
optional: bool,
},
@@ -35,12 +33,7 @@ impl ReplyRule {
{
if let Some(reply) = doc.doc_meta().reply() {
let reply_validator = |replied_doc: CatalystSignedDocument| {
- if !referenced_doc_check(
- &replied_doc,
- exp_reply_type.uuid(),
- "reply",
- doc.report(),
- ) {
+ if !referenced_doc_check(&replied_doc, exp_reply_type, "reply", doc.report()) {
return false;
}
let Some(doc_ref) = doc.doc_meta().doc_ref() else {
@@ -165,7 +158,7 @@ mod tests {
// all correct
let rule = ReplyRule::Specified {
- exp_reply_type,
+ exp_reply_type: exp_reply_type.into(),
optional: false,
};
let doc = Builder::new()
@@ -179,7 +172,7 @@ mod tests {
// all correct, `reply` field is missing, but its optional
let rule = ReplyRule::Specified {
- exp_reply_type,
+ exp_reply_type: exp_reply_type.into(),
optional: true,
};
let doc = Builder::new().build();
@@ -187,7 +180,7 @@ mod tests {
// missing `reply` field, but its required
let rule = ReplyRule::Specified {
- exp_reply_type,
+ exp_reply_type: exp_reply_type.into(),
optional: false,
};
let doc = Builder::new()
diff --git a/rust/signed_doc/src/validator/rules/template.rs b/rust/signed_doc/src/validator/rules/template.rs
index 0d5b0c9aaa..13fea3b544 100644
--- a/rust/signed_doc/src/validator/rules/template.rs
+++ b/rust/signed_doc/src/validator/rules/template.rs
@@ -2,12 +2,12 @@
use std::fmt::Write;
-use catalyst_types::uuid::UuidV4;
-
use super::doc_ref::referenced_doc_check;
use crate::{
- metadata::ContentType, providers::CatalystSignedDocumentProvider,
- validator::utils::validate_provided_doc, CatalystSignedDocument,
+ metadata::{ContentType, DocType},
+ providers::CatalystSignedDocumentProvider,
+ validator::utils::validate_provided_doc,
+ CatalystSignedDocument,
};
/// Enum represents different content schemas, against which documents content would be
@@ -23,7 +23,7 @@ pub(crate) enum ContentRule {
/// Based on the 'template' field and loaded corresponding template document
Templated {
/// expected `type` field of the template
- exp_template_type: UuidV4,
+ exp_template_type: DocType,
},
/// Statically defined document's content schema.
/// `template` field should not been specified
@@ -48,12 +48,8 @@ impl ContentRule {
};
let template_validator = |template_doc: CatalystSignedDocument| {
- if !referenced_doc_check(
- &template_doc,
- exp_template_type.uuid(),
- "template",
- doc.report(),
- ) {
+ if !referenced_doc_check(&template_doc, exp_template_type, "template", doc.report())
+ {
return false;
}
@@ -181,7 +177,7 @@ fn content_schema_check(doc: &CatalystSignedDocument, schema: &ContentSchema) ->
#[cfg(test)]
mod tests {
- use catalyst_types::uuid::UuidV7;
+ use catalyst_types::uuid::{UuidV4, UuidV7};
use super::*;
use crate::{providers::tests::TestCatalystSignedDocumentProvider, Builder};
@@ -281,7 +277,9 @@ mod tests {
}
// all correct
- let rule = ContentRule::Templated { exp_template_type };
+ 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() }
@@ -300,7 +298,9 @@ mod tests {
assert!(!rule.check(&doc, &provider).await.unwrap());
// missing content
- let rule = ContentRule::Templated { exp_template_type };
+ 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() }
@@ -310,7 +310,9 @@ mod tests {
assert!(!rule.check(&doc, &provider).await.unwrap());
// content not a json encoded
- let rule = ContentRule::Templated { exp_template_type };
+ 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() }
@@ -341,7 +343,9 @@ mod tests {
assert!(!rule.check(&doc, &provider).await.unwrap());
// missing `content-type` field in the referenced doc
- let rule = ContentRule::Templated { exp_template_type };
+ let rule = ContentRule::Templated {
+ exp_template_type: exp_template_type.into(),
+ };
let doc = Builder::new()
.with_json_metadata(serde_json::json!({
"template": {"id": missing_content_type_template_doc_id.to_string(), "ver": missing_content_type_template_doc_id.to_string() }
diff --git a/rust/signed_doc/tests/comment.rs b/rust/signed_doc/tests/comment.rs
index 1c746e589c..5725e3080c 100644
--- a/rust/signed_doc/tests/comment.rs
+++ b/rust/signed_doc/tests/comment.rs
@@ -8,16 +8,55 @@ mod common;
#[tokio::test]
async fn test_valid_comment_doc() {
let (proposal_doc, proposal_doc_id, proposal_doc_ver) =
- common::create_dummy_doc(doc_types::PROPOSAL_DOCUMENT_UUID_TYPE).unwrap();
+ common::create_dummy_doc(doc_types::PROPOSAL_UUID_TYPE).unwrap();
let (template_doc, template_doc_id, template_doc_ver) =
- common::create_dummy_doc(doc_types::COMMENT_TEMPLATE_UUID_TYPE).unwrap();
+ common::create_dummy_doc(doc_types::deprecated::COMMENT_TEMPLATE_UUID_TYPE).unwrap();
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::COMMENT_DOCUMENT_UUID_TYPE,
+ "type": doc_types::PROPOSAL_COMMENT_DOC.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
+ }
+ }),
+ 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_old_type() {
+ let (proposal_doc, proposal_doc_id, proposal_doc_ver) =
+ common::create_dummy_doc(doc_types::PROPOSAL_UUID_TYPE).unwrap();
+ let (template_doc, template_doc_id, template_doc_ver) =
+ common::create_dummy_doc(doc_types::deprecated::COMMENT_TEMPLATE_UUID_TYPE).unwrap();
+
+ 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(),
+ // Using old (single uuid)
+ "type": doc_types::deprecated::COMMENT_DOCUMENT_UUID_TYPE,
"id": uuid_v7.to_string(),
"ver": uuid_v7.to_string(),
"template": {
@@ -48,9 +87,9 @@ 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(doc_types::PROPOSAL_DOCUMENT_UUID_TYPE).unwrap();
+ common::create_dummy_doc(doc_types::PROPOSAL_UUID_TYPE).unwrap();
let (template_doc, template_doc_id, template_doc_ver) =
- common::create_dummy_doc(doc_types::COMMENT_TEMPLATE_UUID_TYPE).unwrap();
+ common::create_dummy_doc(doc_types::deprecated::COMMENT_TEMPLATE_UUID_TYPE).unwrap();
let comment_doc_id = UuidV7::new();
let comment_doc_ver = UuidV7::new();
@@ -58,7 +97,7 @@ async fn test_valid_comment_doc_with_reply() {
.with_json_metadata(serde_json::json!({
"id": comment_doc_id,
"ver": comment_doc_ver,
- "type": doc_types::COMMENT_DOCUMENT_UUID_TYPE,
+ "type": doc_types::PROPOSAL_COMMENT_DOC.clone(),
"content-type": ContentType::Json.to_string(),
"template": { "id": template_doc_id.to_string(), "ver": template_doc_ver.to_string() },
"ref": {
@@ -75,7 +114,7 @@ async fn test_valid_comment_doc_with_reply() {
serde_json::json!({
"content-type": ContentType::Json.to_string(),
"content-encoding": ContentEncoding::Brotli.to_string(),
- "type": doc_types::COMMENT_DOCUMENT_UUID_TYPE,
+ "type": doc_types::PROPOSAL_COMMENT_DOC.clone(),
"id": uuid_v7.to_string(),
"ver": uuid_v7.to_string(),
"template": {
@@ -108,17 +147,16 @@ async fn test_valid_comment_doc_with_reply() {
#[tokio::test]
async fn test_invalid_comment_doc() {
- let (proposal_doc, ..) =
- common::create_dummy_doc(doc_types::PROPOSAL_DOCUMENT_UUID_TYPE).unwrap();
+ let (proposal_doc, ..) = common::create_dummy_doc(doc_types::PROPOSAL_UUID_TYPE).unwrap();
let (template_doc, template_doc_id, template_doc_ver) =
- common::create_dummy_doc(doc_types::COMMENT_TEMPLATE_UUID_TYPE).unwrap();
+ common::create_dummy_doc(doc_types::deprecated::COMMENT_TEMPLATE_UUID_TYPE).unwrap();
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::COMMENT_DOCUMENT_UUID_TYPE,
+ "type": doc_types::PROPOSAL_COMMENT_DOC.clone(),
"id": uuid_v7.to_string(),
"ver": uuid_v7.to_string(),
"template": {
diff --git a/rust/signed_doc/tests/common/mod.rs b/rust/signed_doc/tests/common/mod.rs
index d7ea84150b..ae3d348f8a 100644
--- a/rust/signed_doc/tests/common/mod.rs
+++ b/rust/signed_doc/tests/common/mod.rs
@@ -27,6 +27,29 @@ pub fn test_metadata() -> (UuidV7, UuidV4, serde_json::Value) {
(uuid_v7, uuid_v4, metadata_fields)
}
+pub fn test_metadata_specific_type(
+ uuid_v4: Option, uuid_v7: Option,
+) -> (UuidV7, UuidV4, serde_json::Value) {
+ let uuid_v7 = uuid_v7.unwrap_or_else(UuidV7::new);
+ let uuid_v4 = uuid_v4.unwrap_or_else(UuidV4::new);
+
+ let metadata_fields = serde_json::json!({
+ "content-type": ContentType::Json.to_string(),
+ "content-encoding": ContentEncoding::Brotli.to_string(),
+ "type": uuid_v4.to_string(),
+ "id": uuid_v7.to_string(),
+ "ver": uuid_v7.to_string(),
+ "ref": {"id": uuid_v7.to_string(), "ver": uuid_v7.to_string()},
+ "reply": {"id": uuid_v7.to_string(), "ver": uuid_v7.to_string()},
+ "template": {"id": uuid_v7.to_string(), "ver": uuid_v7.to_string()},
+ "section": "$".to_string(),
+ "collabs": vec!["Alex1".to_string(), "Alex2".to_string()],
+ "parameters": {"id": uuid_v7.to_string(), "ver": uuid_v7.to_string()},
+ });
+
+ (uuid_v7, uuid_v4, metadata_fields)
+}
+
pub fn create_dummy_key_pair(
role_index: RoleId,
) -> anyhow::Result<(
diff --git a/rust/signed_doc/tests/decoding.rs b/rust/signed_doc/tests/decoding.rs
index c1f632f84a..795183e699 100644
--- a/rust/signed_doc/tests/decoding.rs
+++ b/rust/signed_doc/tests/decoding.rs
@@ -9,36 +9,17 @@ use ed25519_dalek::ed25519::signature::Signer;
mod common;
#[test]
-fn catalyst_signed_doc_cbor_roundtrip_test() {
- let (uuid_v7, uuid_v4, metadata_fields) = common::test_metadata();
- let (sk, _, kid) = create_dummy_key_pair(RoleId::Role0).unwrap();
-
- let content = serde_json::to_vec(&serde_json::Value::Null).unwrap();
-
- let doc = Builder::new()
- .with_json_metadata(metadata_fields.clone())
- .unwrap()
- .with_decoded_content(content.clone())
- .add_signature(|m| sk.sign(&m).to_vec(), &kid)
- .unwrap()
- .build();
-
- assert!(!doc.problem_report().is_problematic());
-
- let bytes: Vec = doc.try_into().unwrap();
- let decoded: CatalystSignedDocument = bytes.as_slice().try_into().unwrap();
- let extra_fields: ExtraFields = serde_json::from_value(metadata_fields).unwrap();
-
- assert_eq!(decoded.doc_type().unwrap(), uuid_v4);
- assert_eq!(decoded.doc_id().unwrap(), uuid_v7);
- assert_eq!(decoded.doc_ver().unwrap(), uuid_v7);
- assert_eq!(decoded.doc_content().decoded_bytes().unwrap(), &content);
- assert_eq!(decoded.doc_meta(), &extra_fields);
+fn catalyst_signed_doc_cbor_roundtrip_kid_as_id_test() {
+ catalyst_signed_doc_cbor_roundtrip_kid_as_id(common::test_metadata());
+ catalyst_signed_doc_cbor_roundtrip_kid_as_id(common::test_metadata_specific_type(
+ Some(doc_types::PROPOSAL_UUID_TYPE.try_into().unwrap()),
+ None,
+ ));
}
-#[test]
-fn catalyst_signed_doc_cbor_roundtrip_kid_as_id_test() {
- let (_, _, metadata_fields) = common::test_metadata();
+#[allow(clippy::unwrap_used)]
+fn catalyst_signed_doc_cbor_roundtrip_kid_as_id(data: (UuidV7, UuidV4, serde_json::Value)) {
+ let (_, _, metadata_fields) = data;
let (sk, _, kid) = create_dummy_key_pair(RoleId::Role0).unwrap();
// transform Catalyst ID URI form to the ID form
let kid = kid.as_id();
@@ -57,9 +38,19 @@ fn catalyst_signed_doc_cbor_roundtrip_kid_as_id_test() {
}
#[tokio::test]
-#[allow(clippy::too_many_lines)]
async fn catalyst_signed_doc_parameters_aliases_test() {
- let (_, _, metadata_fields) = common::test_metadata();
+ catalyst_signed_doc_parameters_aliases(common::test_metadata()).await;
+ catalyst_signed_doc_parameters_aliases(common::test_metadata_specific_type(
+ Some(doc_types::PROPOSAL_UUID_TYPE.try_into().unwrap()),
+ None,
+ ))
+ .await;
+}
+
+#[allow(clippy::unwrap_used)]
+#[allow(clippy::too_many_lines)]
+async fn catalyst_signed_doc_parameters_aliases(data: (UuidV7, UuidV4, serde_json::Value)) {
+ let (_, _, metadata_fields) = data;
let (sk, pk, kid) = common::create_dummy_key_pair(RoleId::Role0).unwrap();
let mut provider = TestVerifyingKeyProvider::default();
provider.add_pk(kid.clone(), pk);
@@ -181,3 +172,133 @@ async fn catalyst_signed_doc_parameters_aliases_test() {
.unwrap();
assert!(doc.problem_report().is_problematic());
}
+
+type PostCheck = dyn Fn(&CatalystSignedDocument) -> bool;
+
+struct TestCase {
+ name: &'static str,
+ bytes_gen: Box Vec>,
+ // If the provided bytes can be even decoded without error (valid COSE or not).
+ // If set to `false` all further checks will not even happen.
+ can_decode: bool,
+ // If the decoded doc is a valid `CatalystSignedDocument`, underlying problem report is empty.
+ valid_doc: bool,
+ post_checks: Option>,
+}
+
+fn decoding_empty_bytes_case() -> TestCase {
+ TestCase {
+ name: "Decoding empty bytes",
+ bytes_gen: Box::new(Vec::new),
+ can_decode: false,
+ valid_doc: false,
+ post_checks: None,
+ }
+}
+
+#[allow(clippy::unwrap_used)]
+fn signed_doc_with_all_fields_case() -> TestCase {
+ let uuid_v7 = UuidV7::new();
+ let uuid_v4 = UuidV4::new();
+ let (sk, _, kid) = create_dummy_key_pair(RoleId::Role0).unwrap();
+
+ TestCase {
+ name: "Catalyst Signed Doc with ALL defined metadata fields and signatures",
+ bytes_gen: Box::new({
+ let kid = kid.clone();
+ move || {
+ Builder::new()
+ .with_json_metadata(serde_json::json!({
+ "content-type": ContentType::Json.to_string(),
+ "content-encoding": ContentEncoding::Brotli.to_string(),
+ "type": uuid_v4.to_string(),
+ "id": uuid_v7.to_string(),
+ "ver": uuid_v7.to_string(),
+ "ref": {"id": uuid_v7.to_string(), "ver": uuid_v7.to_string()},
+ "reply": {"id": uuid_v7.to_string(), "ver": uuid_v7.to_string()},
+ "template": {"id": uuid_v7.to_string(), "ver": uuid_v7.to_string()},
+ "section": "$".to_string(),
+ "collabs": vec!["Alex1".to_string(), "Alex2".to_string()],
+ "parameters": {"id": uuid_v7.to_string(), "ver": uuid_v7.to_string()},
+ }))
+ .unwrap()
+ .with_decoded_content(serde_json::to_vec(&serde_json::Value::Null).unwrap())
+ .add_signature(|m| sk.sign(&m).to_vec(), &kid)
+ .unwrap()
+ .build()
+ .try_into()
+ .unwrap()
+ }
+ }),
+ can_decode: true,
+ valid_doc: true,
+ post_checks: Some(Box::new({
+ move |doc| {
+ (doc.doc_type().unwrap() == &DocType::from(uuid_v4))
+ && (doc.doc_id().unwrap() == uuid_v7)
+ && (doc.doc_ver().unwrap() == uuid_v7)
+ && (doc.doc_content_type().unwrap() == ContentType::Json)
+ && (doc.doc_content_encoding().unwrap() == ContentEncoding::Brotli)
+ && (doc.doc_meta().doc_ref().unwrap()
+ == DocumentRef {
+ id: uuid_v7,
+ ver: uuid_v7,
+ })
+ && (doc.doc_meta().reply().unwrap()
+ == DocumentRef {
+ id: uuid_v7,
+ ver: uuid_v7,
+ })
+ && (doc.doc_meta().template().unwrap()
+ == DocumentRef {
+ id: uuid_v7,
+ ver: uuid_v7,
+ })
+ && (doc.doc_meta().parameters().unwrap()
+ == DocumentRef {
+ id: uuid_v7,
+ ver: uuid_v7,
+ })
+ && (doc.doc_meta().section().unwrap() == &"$".parse::().unwrap())
+ && (doc.doc_meta().collabs() == ["Alex1".to_string(), "Alex2".to_string()])
+ && (doc.doc_content().decoded_bytes().unwrap()
+ == serde_json::to_vec(&serde_json::Value::Null).unwrap())
+ && (doc.kids() == vec![kid.clone()])
+ }
+ })),
+ }
+}
+
+#[test]
+fn catalyst_signed_doc_decoding_test() {
+ let test_cases = [
+ decoding_empty_bytes_case(),
+ signed_doc_with_all_fields_case(),
+ ];
+
+ for case in test_cases {
+ let bytes = case.bytes_gen.as_ref()();
+ let doc_res = CatalystSignedDocument::try_from(bytes.as_slice());
+ assert_eq!(doc_res.is_ok(), case.can_decode, "Case: [{}]", case.name);
+ if let Ok(doc) = doc_res {
+ assert_eq!(
+ !doc.problem_report().is_problematic(),
+ case.valid_doc,
+ "Case: [{}]. Problem report: {:?}",
+ case.name,
+ doc.problem_report()
+ );
+
+ if let Some(post_checks) = &case.post_checks {
+ assert!((post_checks.as_ref())(&doc), "Case: [{}]", case.name);
+ }
+
+ assert_eq!(
+ bytes,
+ Vec::::try_from(doc).unwrap(),
+ "Case: [{}]. Asymmetric encoding and decoding procedure",
+ case.name
+ );
+ }
+ }
+}
diff --git a/rust/signed_doc/tests/proposal.rs b/rust/signed_doc/tests/proposal.rs
index 50ce1799e4..7e6f4f21d7 100644
--- a/rust/signed_doc/tests/proposal.rs
+++ b/rust/signed_doc/tests/proposal.rs
@@ -8,14 +8,46 @@ mod common;
#[tokio::test]
async fn test_valid_proposal_doc() {
let (template_doc, template_doc_id, template_doc_ver) =
- common::create_dummy_doc(doc_types::PROPOSAL_TEMPLATE_UUID_TYPE).unwrap();
+ common::create_dummy_doc(doc_types::deprecated::PROPOSAL_TEMPLATE_UUID_TYPE).unwrap();
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_DOCUMENT_UUID_TYPE,
+ "type": doc_types::PROPOSAL_DOC_TYPE.clone(),
+ "id": uuid_v7.to_string(),
+ "ver": uuid_v7.to_string(),
+ "template": {
+ "id": template_doc_id,
+ "ver": template_doc_ver
+ },
+ }),
+ serde_json::to_vec(&serde_json::Value::Null).unwrap(),
+ RoleId::Proposer,
+ )
+ .unwrap();
+
+ let mut provider = TestCatalystSignedDocumentProvider::default();
+ provider.add_document(template_doc).unwrap();
+
+ let is_valid = validator::validate(&doc, &provider).await.unwrap();
+
+ assert!(is_valid);
+}
+
+#[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 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(),
+ // Using old (single uuid)
+ "type": doc_types::deprecated::PROPOSAL_DOCUMENT_UUID_TYPE,
"id": uuid_v7.to_string(),
"ver": uuid_v7.to_string(),
"template": {
@@ -47,7 +79,7 @@ async fn test_valid_proposal_doc_with_empty_provider() {
serde_json::json!({
"content-type": ContentType::Json.to_string(),
"content-encoding": ContentEncoding::Brotli.to_string(),
- "type": doc_types::PROPOSAL_DOCUMENT_UUID_TYPE,
+ "type": doc_types::PROPOSAL_DOC_TYPE.clone(),
"id": uuid_v7.to_string(),
"ver": uuid_v7.to_string(),
"template": {
@@ -74,7 +106,7 @@ async fn test_invalid_proposal_doc() {
serde_json::json!({
"content-type": ContentType::Json.to_string(),
"content-encoding": ContentEncoding::Brotli.to_string(),
- "type": doc_types::PROPOSAL_DOCUMENT_UUID_TYPE,
+ "type": doc_types::PROPOSAL_DOC_TYPE.clone(),
"id": uuid_v7.to_string(),
"ver": uuid_v7.to_string(),
// without specifying template id
diff --git a/rust/signed_doc/tests/submission.rs b/rust/signed_doc/tests/submission.rs
index d10c6c3952..dc2ea5d56a 100644
--- a/rust/signed_doc/tests/submission.rs
+++ b/rust/signed_doc/tests/submission.rs
@@ -8,14 +8,47 @@ mod common;
#[tokio::test]
async fn test_valid_submission_action() {
let (proposal_doc, proposal_doc_id, proposal_doc_ver) =
- common::create_dummy_doc(doc_types::PROPOSAL_DOCUMENT_UUID_TYPE).unwrap();
+ common::create_dummy_doc(doc_types::PROPOSAL_UUID_TYPE).unwrap();
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_ACTION_DOCUMENT_UUID_TYPE,
+ "type": doc_types::PROPOSAL_ACTION_DOC.clone(),
+ "id": uuid_v7.to_string(),
+ "ver": uuid_v7.to_string(),
+ "ref": {
+ "id": proposal_doc_id,
+ "ver": proposal_doc_ver
+ },
+ }),
+ serde_json::to_vec(&serde_json::json!({
+ "action": "final"
+ }))
+ .unwrap(),
+ RoleId::Proposer,
+ )
+ .unwrap();
+
+ let mut provider = TestCatalystSignedDocumentProvider::default();
+ provider.add_document(proposal_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(doc_types::PROPOSAL_UUID_TYPE).unwrap();
+
+ 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(),
+ // Using old (single uuid)
+ "type": doc_types::deprecated::PROPOSAL_ACTION_DOCUMENT_UUID_TYPE,
"id": uuid_v7.to_string(),
"ver": uuid_v7.to_string(),
"ref": {
@@ -47,7 +80,7 @@ async fn test_valid_submission_action_with_empty_provider() {
serde_json::json!({
"content-type": ContentType::Json.to_string(),
"content-encoding": ContentEncoding::Brotli.to_string(),
- "type": doc_types::PROPOSAL_ACTION_DOCUMENT_UUID_TYPE,
+ "type": doc_types::PROPOSAL_ACTION_DOC.clone(),
"id": uuid_v7.to_string(),
"ver": uuid_v7.to_string(),
"ref": {
@@ -78,7 +111,7 @@ async fn test_invalid_submission_action() {
serde_json::json!({
"content-type": ContentType::Json.to_string(),
"content-encoding": ContentEncoding::Brotli.to_string(),
- "type": doc_types::PROPOSAL_ACTION_DOCUMENT_UUID_TYPE,
+ "type": doc_types::PROPOSAL_ACTION_DOC.clone(),
"id": uuid_v7.to_string(),
"ver": uuid_v7.to_string(),
// without specifying ref
@@ -98,13 +131,13 @@ async fn test_invalid_submission_action() {
// corrupted JSON
let (proposal_doc, proposal_doc_id, proposal_doc_ver) =
- common::create_dummy_doc(doc_types::PROPOSAL_DOCUMENT_UUID_TYPE).unwrap();
+ common::create_dummy_doc(doc_types::PROPOSAL_UUID_TYPE).unwrap();
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_ACTION_DOCUMENT_UUID_TYPE,
+ "type": doc_types::ACTION_UUID_TYPE,
"id": uuid_v7.to_string(),
"ver": uuid_v7.to_string(),
"ref": {
@@ -124,13 +157,13 @@ async fn test_invalid_submission_action() {
// empty content
let (proposal_doc, proposal_doc_id, proposal_doc_ver) =
- common::create_dummy_doc(doc_types::PROPOSAL_DOCUMENT_UUID_TYPE).unwrap();
+ common::create_dummy_doc(doc_types::PROPOSAL_UUID_TYPE).unwrap();
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_ACTION_DOCUMENT_UUID_TYPE,
+ "type": doc_types::PROPOSAL_ACTION_DOC.clone(),
"id": uuid_v7.to_string(),
"ver": uuid_v7.to_string(),
"ref": {