From f9cd7180af745c7ddf6b0a8e8d63ba91d18e0c3b Mon Sep 17 00:00:00 2001 From: no30bit Date: Mon, 9 Jun 2025 17:24:26 +0300 Subject: [PATCH 1/6] feat: metadata enum map representation --- rust/signed_doc/Cargo.toml | 2 +- rust/signed_doc/src/decode_context.rs | 1 + rust/signed_doc/src/lib.rs | 9 +- rust/signed_doc/src/metadata/extra_fields.rs | 239 -------------- rust/signed_doc/src/metadata/mod.rs | 302 ++++++++++++++++-- .../src/metadata/supported_field.rs | 284 ++++++++++++++++ 6 files changed, 562 insertions(+), 275 deletions(-) delete mode 100644 rust/signed_doc/src/metadata/extra_fields.rs create mode 100644 rust/signed_doc/src/metadata/supported_field.rs 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..a05b847ff86 100644 --- a/rust/signed_doc/src/metadata/mod.rs +++ b/rust/signed_doc/src/metadata/mod.rs @@ -1,12 +1,15 @@ //! Catalyst Signed Document Metadata. -use std::fmt::{Display, Formatter}; +use std::{ + collections::BTreeMap, + 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 +18,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; 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 +37,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 +80,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 +128,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 +138,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 +150,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 +163,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 reference to additional metadata fields. + /// Return `section` field. #[must_use] - pub fn extra(&self) -> &ExtraFields { - &self.0.extra + 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 +250,12 @@ impl Metadata { ); } - Self(metadata) + Self( + metadata + .into_iter() + .map(|field| (field.discriminant(), field)) + .collect(), + ) } /// Converting COSE Protected Header to Metadata. @@ -142,6 +270,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 +281,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 +342,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 +433,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,7 +475,30 @@ 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()) } 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..e8646c29abb --- /dev/null +++ b/rust/signed_doc/src/metadata/supported_field.rs @@ -0,0 +1,284 @@ +//! Catalyst Signed Document unified metadata field. + +use std::cmp; +#[cfg(test)] +use std::convert::Infallible; + +use catalyst_types::{ + problem_report::ProblemReport, + uuid::{self, UuidV7}, +}; +use strum::{EnumDiscriminants, EnumTryAs, IntoDiscriminant as _}; + +use crate::{CompatibilityPolicy, ContentEncoding, ContentType, DocType, DocumentRef, Section}; + +/// COSE label. May be either a signed integer or a string. +#[derive(Copy, Clone, Eq, PartialEq)] +enum Label<'a> { + /// Signed integer label fitting into [`i8`]. + I8(i8), + /// 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::I8(u) => e.i8(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::I8 => d.i8().map(Self::I8), + 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) + } +} + +/// 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::I8(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::I8(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"), + } + } +} + +/// [`SupportedField`] decoding context for the [`minicbor::Decode`] implementation. +pub struct DecodeContext { + /// Key of the previously decoded field. Used to check for duplicates and invalid + /// ordering. + pub prev_key: Option, + /// Used by some values' decoding implementations. + pub uuid_context: uuid::CborContext, + /// Used by some values' decoding implementations. + pub compatibility_policy: CompatibilityPolicy, + /// Used by some values' decoding implementations. + pub report: ProblemReport, +} + +impl DecodeContext { + /// [`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, + } + } +} + +impl minicbor::Decode<'_, DecodeContext> for SupportedField { + #[allow(clippy::todo, reason = "Not migrated to `minicbor` yet.")] + fn decode( + d: &mut minicbor::Decoder<'_>, ctx: &mut DecodeContext, + ) -> Result { + let label_pos = d.position(); + let label = Label::decode(d, &mut ())?; + let Some(key) = SupportedLabel::from_cose(label) else { + return Err(minicbor::decode::Error::message("Not a supported key").at(label_pos))?; + }; + + match ctx.prev_key.map(|prev_key| prev_key.cmp(&key)) { + Some(cmp::Ordering::Equal) => { + return Err(minicbor::decode::Error::message("Duplicate keys").at(label_pos)); + }, + Some(cmp::Ordering::Greater) => { + return Err(minicbor::decode::Error::message("Invalid key ordering").at(label_pos)); + }, + _ => (), + } + + 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!(), + } + } +} + +/// [`SupportedField`] encoding context for the [`minicbor::Encode`] implementation. +pub struct EncodeContext { + /// Used by some values' encoding implementations. + pub uuid_context: uuid::CborContext, + /// Used by some values' encoding implementations. + pub report: ProblemReport, +} + +impl minicbor::Encode for SupportedField { + #[allow(clippy::todo, reason = "Not migrated to `minicbor` yet.")] + fn encode( + &self, e: &mut minicbor::Encoder, ctx: &mut EncodeContext, + ) -> 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::I8(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); + } +} From 4df5722c4f8eca1b11d89bb23e34155c6b9e304e Mon Sep 17 00:00:00 2001 From: no30bit Date: Mon, 9 Jun 2025 18:49:44 +0300 Subject: [PATCH 2/6] add encode/decode impl for Metadata --- rust/signed_doc/src/metadata/mod.rs | 94 ++++++++++++++++++- .../src/metadata/supported_field.rs | 75 +++++++-------- 2 files changed, 131 insertions(+), 38 deletions(-) diff --git a/rust/signed_doc/src/metadata/mod.rs b/rust/signed_doc/src/metadata/mod.rs index a05b847ff86..d6e907c9c01 100644 --- a/rust/signed_doc/src/metadata/mod.rs +++ b/rust/signed_doc/src/metadata/mod.rs @@ -20,7 +20,7 @@ pub use doc_type::DocType; pub use document_ref::DocumentRef; use minicbor::{Decode, Decoder}; pub use section::Section; -use strum::IntoDiscriminant; +use strum::IntoDiscriminant as _; use utils::{cose_protected_header_find, decode_document_field_from_protected_header, CborUuidV7}; use crate::{ @@ -503,3 +503,95 @@ impl TryFrom<&Metadata> for coset::Header { 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, + } + } + + /// First in a map [`SupportedField`] decoding context. + fn first_field_context(&mut self) -> supported_field::DecodeContext { + supported_field::DecodeContext { + prev_key: None, + metadata_context: self, + } + } +} + +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 { + let Some(len) = d.map()? else { + return Err(minicbor::decode::Error::message( + "Indefinite map is not supported", + )); + }; + let mut field_ctx = ctx.first_field_context(); + + // This performs duplication, ordering and length mismatch checks. + (0..len) + .map(|_| { + d.decode_with(&mut field_ctx) + .map(|field: SupportedField| (field.discriminant(), field)) + }) + .collect::>() + .map(Self) + } +} diff --git a/rust/signed_doc/src/metadata/supported_field.rs b/rust/signed_doc/src/metadata/supported_field.rs index e8646c29abb..32a1984c8ec 100644 --- a/rust/signed_doc/src/metadata/supported_field.rs +++ b/rust/signed_doc/src/metadata/supported_field.rs @@ -1,16 +1,19 @@ //! Catalyst Signed Document unified metadata field. -use std::cmp; #[cfg(test)] use std::convert::Infallible; - -use catalyst_types::{ - problem_report::ProblemReport, - uuid::{self, UuidV7}, +use std::{ + cmp, + ops::{Deref, DerefMut}, }; + +use catalyst_types::uuid::UuidV7; use strum::{EnumDiscriminants, EnumTryAs, IntoDiscriminant as _}; -use crate::{CompatibilityPolicy, ContentEncoding, ContentType, DocType, DocumentRef, Section}; +use crate::{ + metadata::{MetadataDecodeContext, MetadataEncodeContext}, + ContentEncoding, ContentType, DocType, DocumentRef, Section, +}; /// COSE label. May be either a signed integer or a string. #[derive(Copy, Clone, Eq, PartialEq)] @@ -38,10 +41,12 @@ impl<'a, C> minicbor::Decode<'a, C> for Label<'a> { match d.datatype()? { minicbor::data::Type::I8 => d.i8().map(Self::I8), 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())), + _ => { + Err(minicbor::decode::Error::message( + "Datatype is neither 8bit signed integer nor text", + ) + .at(d.position())) + }, } } } @@ -144,29 +149,29 @@ impl SupportedLabel { } /// [`SupportedField`] decoding context for the [`minicbor::Decode`] implementation. -pub struct DecodeContext { +pub(crate) struct DecodeContext<'a> { /// Key of the previously decoded field. Used to check for duplicates and invalid /// ordering. pub prev_key: Option, /// Used by some values' decoding implementations. - pub uuid_context: uuid::CborContext, - /// Used by some values' decoding implementations. - pub compatibility_policy: CompatibilityPolicy, - /// Used by some values' decoding implementations. - pub report: ProblemReport, + pub metadata_context: &'a mut MetadataDecodeContext, } -impl DecodeContext { - /// [`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, - } +impl Deref for DecodeContext<'_> { + type Target = MetadataDecodeContext; + + fn deref(&self) -> &Self::Target { + self.metadata_context } } -impl minicbor::Decode<'_, DecodeContext> for SupportedField { +impl DerefMut for DecodeContext<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.metadata_context + } +} + +impl minicbor::Decode<'_, DecodeContext<'_>> for SupportedField { #[allow(clippy::todo, reason = "Not migrated to `minicbor` yet.")] fn decode( d: &mut minicbor::Decoder<'_>, ctx: &mut DecodeContext, @@ -187,7 +192,7 @@ impl minicbor::Decode<'_, DecodeContext> for SupportedField { _ => (), } - match key { + let field = match key { SupportedLabel::ContentType => todo!(), SupportedLabel::Id => d.decode_with(&mut ctx.uuid_context).map(Self::Id), SupportedLabel::Ref => todo!(), @@ -199,22 +204,17 @@ impl minicbor::Decode<'_, DecodeContext> for SupportedField { SupportedLabel::Template => todo!(), SupportedLabel::Parameters => todo!(), SupportedLabel::ContentEncoding => todo!(), - } - } -} + }?; -/// [`SupportedField`] encoding context for the [`minicbor::Encode`] implementation. -pub struct EncodeContext { - /// Used by some values' encoding implementations. - pub uuid_context: uuid::CborContext, - /// Used by some values' encoding implementations. - pub report: ProblemReport, + ctx.prev_key = Some(key); + Ok(field) + } } -impl minicbor::Encode for SupportedField { +impl minicbor::Encode for SupportedField { #[allow(clippy::todo, reason = "Not migrated to `minicbor` yet.")] fn encode( - &self, e: &mut minicbor::Encoder, ctx: &mut EncodeContext, + &self, e: &mut minicbor::Encoder, ctx: &mut MetadataEncodeContext, ) -> Result<(), minicbor::encode::Error> { let key = self.discriminant().to_cose(); e.encode(key)?; @@ -268,7 +268,8 @@ mod tests { ); } - /// Checks that [`SupportedLabel`] enum integer values correspond to [`Label::rfc8949_cmp`] ordering. + /// 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(); From 7135e5469ef929b10a05a9ddb17a453438f45312 Mon Sep 17 00:00:00 2001 From: no30bit Date: Wed, 11 Jun 2025 13:53:49 +0300 Subject: [PATCH 3/6] fix integer label, upd its doc --- rust/signed_doc/src/metadata/supported_field.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/rust/signed_doc/src/metadata/supported_field.rs b/rust/signed_doc/src/metadata/supported_field.rs index 32a1984c8ec..3eeab69db6b 100644 --- a/rust/signed_doc/src/metadata/supported_field.rs +++ b/rust/signed_doc/src/metadata/supported_field.rs @@ -18,8 +18,8 @@ use crate::{ /// COSE label. May be either a signed integer or a string. #[derive(Copy, Clone, Eq, PartialEq)] enum Label<'a> { - /// Signed integer label fitting into [`i8`]. - I8(i8), + /// Integer label fitting into `0..=23`. + U8(u8), /// Text label. Str(&'a str), } @@ -29,7 +29,7 @@ impl minicbor::Encode for Label<'_> { &self, e: &mut minicbor::Encoder, _: &mut C, ) -> Result<(), minicbor::encode::Error> { match self { - &Label::I8(u) => e.i8(u), + &Label::U8(u) => e.u8(u), Label::Str(s) => e.str(s), }? .ok() @@ -39,7 +39,7 @@ impl minicbor::Encode for Label<'_> { 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::I8 => d.i8().map(Self::I8), + minicbor::data::Type::U8 => d.u8().map(Self::U8), minicbor::data::Type::String => d.str().map(Self::Str), _ => { Err(minicbor::decode::Error::message( @@ -113,7 +113,7 @@ impl SupportedLabel { /// Try to convert from an arbitrary COSE [`Label`]. fn from_cose(label: Label<'_>) -> Option { match label { - Label::I8(3) => Some(Self::ContentType), + Label::U8(3) => Some(Self::ContentType), Label::Str("id") => Some(Self::Id), Label::Str("ref") => Some(Self::Ref), Label::Str("ver") => Some(Self::Ver), @@ -133,7 +133,7 @@ impl SupportedLabel { /// Convert to the corresponding COSE [`Label`]. fn to_cose(self) -> Label<'static> { match self { - Self::ContentType => Label::I8(3), + Self::ContentType => Label::U8(3), Self::Id => Label::Str("id"), Self::Ref => Label::Str("ref"), Self::Ver => Label::Str("ver"), @@ -263,7 +263,7 @@ mod tests { cmp::Ordering::Greater ); assert_eq!( - Label::I8(3).rfc8949_cmp(&Label::Str("id")).unwrap(), + Label::U8(3).rfc8949_cmp(&Label::Str("id")).unwrap(), cmp::Ordering::Less ); } From 4e549e4a7e6ce6bc82e16184ed0c359c17d85a51 Mon Sep 17 00:00:00 2001 From: no30bit Date: Wed, 11 Jun 2025 15:32:06 +0300 Subject: [PATCH 4/6] upd integer label doc. --- rust/signed_doc/src/metadata/supported_field.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/rust/signed_doc/src/metadata/supported_field.rs b/rust/signed_doc/src/metadata/supported_field.rs index 3eeab69db6b..d669fccb83e 100644 --- a/rust/signed_doc/src/metadata/supported_field.rs +++ b/rust/signed_doc/src/metadata/supported_field.rs @@ -18,7 +18,13 @@ use crate::{ /// COSE label. May be either a signed integer or a string. #[derive(Copy, Clone, Eq, PartialEq)] enum Label<'a> { - /// Integer label fitting into `0..=23`. + /// 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), From b94e190dbcdcc2a3d1a469c9f76477b5f8336542 Mon Sep 17 00:00:00 2001 From: no30bit Date: Wed, 11 Jun 2025 20:12:56 +0300 Subject: [PATCH 5/6] use ProblemReport, remove map ord decode validation --- rust/signed_doc/src/metadata/mod.rs | 80 +++++++++++++++---- .../src/metadata/supported_field.rs | 74 ++++++++--------- 2 files changed, 97 insertions(+), 57 deletions(-) diff --git a/rust/signed_doc/src/metadata/mod.rs b/rust/signed_doc/src/metadata/mod.rs index d6e907c9c01..456361fba44 100644 --- a/rust/signed_doc/src/metadata/mod.rs +++ b/rust/signed_doc/src/metadata/mod.rs @@ -1,6 +1,7 @@ //! Catalyst Signed Document Metadata. use std::{ - collections::BTreeMap, + collections::{btree_map, BTreeMap}, + error::Error, fmt::{Display, Formatter}, }; @@ -555,14 +556,27 @@ impl MetadataDecodeContext { report: &mut self.report, } } +} - /// First in a map [`SupportedField`] decoding context. - fn first_field_context(&mut self) -> supported_field::DecodeContext { - supported_field::DecodeContext { - prev_key: None, - metadata_context: self, - } +/// 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(inner) } impl minicbor::Decode<'_, MetadataDecodeContext> for Metadata { @@ -578,20 +592,52 @@ impl minicbor::Decode<'_, MetadataDecodeContext> for Metadata { 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", )); }; - let mut field_ctx = ctx.first_field_context(); - - // This performs duplication, ordering and length mismatch checks. - (0..len) - .map(|_| { - d.decode_with(&mut field_ctx) - .map(|field: SupportedField| (field.discriminant(), field)) - }) - .collect::>() - .map(Self) + + // 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 index d669fccb83e..9ce4ecb122a 100644 --- a/rust/signed_doc/src/metadata/supported_field.rs +++ b/rust/signed_doc/src/metadata/supported_field.rs @@ -1,17 +1,14 @@ //! Catalyst Signed Document unified metadata field. +use std::fmt::{self, Display}; #[cfg(test)] -use std::convert::Infallible; -use std::{ - cmp, - ops::{Deref, DerefMut}, -}; +use std::{cmp, convert::Infallible}; use catalyst_types::uuid::UuidV7; use strum::{EnumDiscriminants, EnumTryAs, IntoDiscriminant as _}; use crate::{ - metadata::{MetadataDecodeContext, MetadataEncodeContext}, + metadata::{custom_transient_decode_error, MetadataDecodeContext, MetadataEncodeContext}, ContentEncoding, ContentType, DocType, DocumentRef, Section, }; @@ -72,6 +69,15 @@ impl Label<'_> { } } +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]). @@ -154,50 +160,39 @@ impl SupportedLabel { } } -/// [`SupportedField`] decoding context for the [`minicbor::Decode`] implementation. -pub(crate) struct DecodeContext<'a> { - /// Key of the previously decoded field. Used to check for duplicates and invalid - /// ordering. - pub prev_key: Option, - /// Used by some values' decoding implementations. - pub metadata_context: &'a mut MetadataDecodeContext, -} - -impl Deref for DecodeContext<'_> { - type Target = MetadataDecodeContext; - - fn deref(&self) -> &Self::Target { - self.metadata_context +impl Display for SupportedLabel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + Display::fmt(&self.to_cose(), f) } } -impl DerefMut for DecodeContext<'_> { - fn deref_mut(&mut self) -> &mut Self::Target { - self.metadata_context - } -} - -impl minicbor::Decode<'_, DecodeContext<'_>> for SupportedField { +impl minicbor::Decode<'_, MetadataDecodeContext> for SupportedField { #[allow(clippy::todo, reason = "Not migrated to `minicbor` yet.")] fn decode( - d: &mut minicbor::Decoder<'_>, ctx: &mut DecodeContext, + 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 { - return Err(minicbor::decode::Error::message("Not a supported key").at(label_pos))?; + 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), + )); }; - match ctx.prev_key.map(|prev_key| prev_key.cmp(&key)) { - Some(cmp::Ordering::Equal) => { - return Err(minicbor::decode::Error::message("Duplicate keys").at(label_pos)); - }, - Some(cmp::Ordering::Greater) => { - return Err(minicbor::decode::Error::message("Invalid key ordering").at(label_pos)); - }, - _ => (), - } - let field = match key { SupportedLabel::ContentType => todo!(), SupportedLabel::Id => d.decode_with(&mut ctx.uuid_context).map(Self::Id), @@ -212,7 +207,6 @@ impl minicbor::Decode<'_, DecodeContext<'_>> for SupportedField { SupportedLabel::ContentEncoding => todo!(), }?; - ctx.prev_key = Some(key); Ok(field) } } From c8b0ef28700b793255b9accf75e0043680b8b01d Mon Sep 17 00:00:00 2001 From: Artur Helmanau Date: Thu, 12 Jun 2025 15:33:20 +0300 Subject: [PATCH 6/6] Update rust/signed_doc/src/metadata/mod.rs --- rust/signed_doc/src/metadata/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/signed_doc/src/metadata/mod.rs b/rust/signed_doc/src/metadata/mod.rs index 456361fba44..10a2688081d 100644 --- a/rust/signed_doc/src/metadata/mod.rs +++ b/rust/signed_doc/src/metadata/mod.rs @@ -576,7 +576,7 @@ fn custom_transient_decode_error( if let Some(pos) = position { inner = inner.at(pos); } - minicbor::decode::Error::custom(inner) + minicbor::decode::Error::custom(TransientDecodeError(inner)) } impl minicbor::Decode<'_, MetadataDecodeContext> for Metadata {