diff --git a/rust/signed_doc/Cargo.toml b/rust/signed_doc/Cargo.toml index f24635b241d..151de0a34f7 100644 --- a/rust/signed_doc/Cargo.toml +++ b/rust/signed_doc/Cargo.toml @@ -21,7 +21,7 @@ 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" diff --git a/rust/signed_doc/src/decode_context.rs b/rust/signed_doc/src/decode_context.rs index 9667fefd371..6c85e9c247d 100644 --- a/rust/signed_doc/src/decode_context.rs +++ b/rust/signed_doc/src/decode_context.rs @@ -4,6 +4,7 @@ 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, diff --git a/rust/signed_doc/src/lib.rs b/rust/signed_doc/src/lib.rs index b7e599ef042..c797d75138b 100644 --- a/rust/signed_doc/src/lib.rs +++ b/rust/signed_doc/src/lib.rs @@ -24,9 +24,7 @@ pub use catalyst_types::{ pub use content::Content; use coset::{CborSerializable, Header, TaggedCborSerializable}; use decode_context::{CompatibilityPolicy, DecodeContext}; -pub use metadata::{ - ContentEncoding, ContentType, DocType, DocumentRef, ExtraFields, Metadata, Section, -}; +pub use metadata::{ContentEncoding, ContentType, DocType, DocumentRef, Metadata, Section}; use minicbor::{decode, encode, Decode, Decoder, Encode}; pub use signature::{CatalystId, Signatures}; @@ -134,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 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 855e0a18323..00000000000 --- 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) -> &[String] { - self.collabs.as_slice() - } - - /// 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 703b71eacc1..10a2688081d 100644 --- a/rust/signed_doc/src/metadata/mod.rs +++ b/rust/signed_doc/src/metadata/mod.rs @@ -1,12 +1,16 @@ //! 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::UuidV7}; @@ -15,12 +19,15 @@ pub use content_type::ContentType; 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 strum::IntoDiscriminant as _; use utils::{cose_protected_header_find, decode_document_field_from_protected_header, CborUuidV7}; -use crate::decode_context::DecodeContext; +use crate::{ + decode_context::DecodeContext, + metadata::supported_field::{SupportedField, SupportedLabel}, +}; /// `content_encoding` field COSE key value const CONTENT_ENCODING_KEY: &str = "Content-Encoding"; @@ -31,13 +38,34 @@ 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, list of `UUIDv4`. @@ -53,9 +81,45 @@ 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 { @@ -65,8 +129,8 @@ impl Metadata { /// - Missing 'type' field. pub fn doc_type(&self) -> anyhow::Result<&DocType> { self.0 - .doc_type - .as_ref() + .get(&SupportedLabel::Type) + .and_then(SupportedField::try_as_type_ref) .ok_or(anyhow::anyhow!("Missing 'type' field")) } @@ -75,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`. @@ -83,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. @@ -92,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 reference to additional metadata fields. + /// Return `template` field. #[must_use] - pub fn extra(&self) -> &ExtraFields { - &self.0.extra + 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 `collabs` field. + #[must_use] + 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. @@ -127,7 +251,12 @@ impl Metadata { ); } - Self(metadata) + Self( + metadata + .into_iter() + .map(|field| (field.discriminant(), field)) + .collect(), + ) } /// Converting COSE Protected Header to Metadata. @@ -142,6 +271,10 @@ impl Metadata { 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, context: &mut DecodeContext, ) -> Self { @@ -149,11 +282,7 @@ impl InnerMetadata { /// header. const COSE_DECODING_CONTEXT: &str = "COSE Protected Header to Metadata"; - let extra = ExtraFields::from_protected_header(protected, context.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) { @@ -214,6 +343,90 @@ impl InnerMetadata { ) .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 } } @@ -221,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, "}}") } } @@ -256,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 00000000000..9ce4ecb122a --- /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); + } +}