• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

input-output-hk / catalyst-libs / 16646523141

31 Jul 2025 10:28AM UTC coverage: 67.341% (+3.8%) from 63.544%
16646523141

push

github

web-flow
feat(rust/signed-doc): Implement new Catalyst Signed Doc (#338)

* chore: add new line to open pr

Signed-off-by: bkioshn <bkioshn@gmail.com>

* chore: revert

Signed-off-by: bkioshn <bkioshn@gmail.com>

* feat(rust/signed-doc): add new type `DocType` (#339)

* feat(signed-doc): add new type DocType

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): add conversion policy

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): doc type

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): doc type error

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): seperate test

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): format

Signed-off-by: bkioshn <bkioshn@gmail.com>

---------

Signed-off-by: bkioshn <bkioshn@gmail.com>

* feat(rust/signed-doc): Add initial decoding tests for the Catalyst Signed Documents (#349)

* wip

* wip

* fix fmt

* fix spelling

* fix clippy

* fix(rust/signed-doc): Apply new `DocType` (#347)

* feat(signed-doc): add new type DocType

Signed-off-by: bkioshn <bkioshn@gmail.com>

* wip: apply doctype

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): add more function to DocType

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): map old doctype to new doctype

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): add eq to uuidv4

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): fix validator

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): minor fixes

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(catalyst-types): add hash to uuidv4

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): decoding test

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): doctype

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): minor fixes

Signed-off-by: bkioshn <bkioshn@gmail.com>

* chore(sign-doc): fix comment

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(catalyst-types): add froms... (continued)

2453 of 2675 new or added lines in 38 files covered. (91.7%)

19 existing lines in 7 files now uncovered.

11312 of 16798 relevant lines covered (67.34%)

2525.16 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.83
/rust/signed_doc/src/metadata/mod.rs
1
//! Catalyst Signed Document Metadata.
2
use std::{
3
    collections::HashMap,
4
    fmt::{Display, Formatter},
5
};
6

7
mod collaborators;
8
mod content_encoding;
9
mod content_type;
10
pub(crate) mod doc_type;
11
mod document_refs;
12
mod section;
13
mod supported_field;
14

15
use catalyst_types::{catalyst_id::CatalystId, problem_report::ProblemReport, uuid::UuidV7};
16
pub use content_encoding::ContentEncoding;
17
pub use content_type::ContentType;
18
pub use doc_type::DocType;
19
pub use document_refs::{DocLocator, DocumentRef, DocumentRefs};
20
use minicbor::Decoder;
21
pub use section::Section;
22
use strum::IntoDiscriminant as _;
23

24
use crate::decode_context::CompatibilityPolicy;
25
pub(crate) use crate::metadata::supported_field::{SupportedField, SupportedLabel};
26

27
/// Document Metadata.
28
///
29
/// These values are extracted from the COSE Sign protected header.
30
#[derive(Clone, Debug, PartialEq, Default)]
31
pub struct Metadata(HashMap<SupportedLabel, SupportedField>);
32

33
impl Metadata {
34
    /// Return Document Type `DocType` - a list of `UUIDv4`.
35
    ///
36
    /// # Errors
37
    /// - Missing 'type' field.
38
    pub fn doc_type(&self) -> anyhow::Result<&DocType> {
342✔
39
        self.0
342✔
40
            .get(&SupportedLabel::Type)
342✔
41
            .and_then(SupportedField::try_as_type_ref)
342✔
42
            .ok_or(anyhow::anyhow!("Missing 'type' field"))
342✔
43
    }
342✔
44

45
    /// Return Document ID `UUIDv7`.
46
    ///
47
    /// # Errors
48
    /// - Missing 'id' field.
49
    pub fn doc_id(&self) -> anyhow::Result<UuidV7> {
478✔
50
        self.0
478✔
51
            .get(&SupportedLabel::Id)
478✔
52
            .and_then(SupportedField::try_as_id_ref)
478✔
53
            .copied()
478✔
54
            .ok_or(anyhow::anyhow!("Missing 'id' field"))
478✔
55
    }
478✔
56

57
    /// Return Document Version `UUIDv7`.
58
    ///
59
    /// # Errors
60
    /// - Missing 'ver' field.
61
    pub fn doc_ver(&self) -> anyhow::Result<UuidV7> {
478✔
62
        self.0
478✔
63
            .get(&SupportedLabel::Ver)
478✔
64
            .and_then(SupportedField::try_as_ver_ref)
478✔
65
            .copied()
478✔
66
            .ok_or(anyhow::anyhow!("Missing 'ver' field"))
478✔
67
    }
478✔
68

69
    /// Returns the Document Content Type, if any.
70
    ///
71
    /// # Errors
72
    /// - Missing 'content-type' field.
73
    pub fn content_type(&self) -> anyhow::Result<ContentType> {
343✔
74
        self.0
343✔
75
            .get(&SupportedLabel::ContentType)
343✔
76
            .and_then(SupportedField::try_as_content_type_ref)
343✔
77
            .copied()
343✔
78
            .ok_or(anyhow::anyhow!("Missing 'content-type' field"))
343✔
79
    }
343✔
80

81
    /// Returns the Document Content Encoding, if any.
82
    #[must_use]
83
    pub fn content_encoding(&self) -> Option<ContentEncoding> {
116✔
84
        self.0
116✔
85
            .get(&SupportedLabel::ContentEncoding)
116✔
86
            .and_then(SupportedField::try_as_content_encoding_ref)
116✔
87
            .copied()
116✔
88
    }
116✔
89

90
    /// Return `ref` field.
91
    #[must_use]
92
    pub fn doc_ref(&self) -> Option<&DocumentRefs> {
62✔
93
        self.0
62✔
94
            .get(&SupportedLabel::Ref)
62✔
95
            .and_then(SupportedField::try_as_ref_ref)
62✔
96
    }
62✔
97

98
    /// Return `template` field.
99
    #[must_use]
100
    pub fn template(&self) -> Option<&DocumentRefs> {
53✔
101
        self.0
53✔
102
            .get(&SupportedLabel::Template)
53✔
103
            .and_then(SupportedField::try_as_template_ref)
53✔
104
    }
53✔
105

106
    /// Return `reply` field.
107
    #[must_use]
108
    pub fn reply(&self) -> Option<&DocumentRefs> {
39✔
109
        self.0
39✔
110
            .get(&SupportedLabel::Reply)
39✔
111
            .and_then(SupportedField::try_as_reply_ref)
39✔
112
    }
39✔
113

114
    /// Return `section` field.
115
    #[must_use]
116
    pub fn section(&self) -> Option<&Section> {
30✔
117
        self.0
30✔
118
            .get(&SupportedLabel::Section)
30✔
119
            .and_then(SupportedField::try_as_section_ref)
30✔
120
    }
30✔
121

122
    /// Return `collaborators` field.
123
    #[must_use]
124
    pub fn collaborators(&self) -> &[CatalystId] {
11✔
125
        self.0
11✔
126
            .get(&SupportedLabel::Collaborators)
11✔
127
            .and_then(SupportedField::try_as_collaborators_ref)
11✔
128
            .map_or(&[], |v| &**v)
11✔
129
    }
11✔
130

131
    /// Return `parameters` field.
132
    #[must_use]
133
    pub fn parameters(&self) -> Option<&DocumentRefs> {
75✔
134
        self.0
75✔
135
            .get(&SupportedLabel::Parameters)
75✔
136
            .and_then(SupportedField::try_as_parameters_ref)
75✔
137
    }
75✔
138

139
    /// Add `SupportedField` into the `Metadata`.
140
    ///
141
    /// # Warning
142
    ///
143
    /// Building metadata by-field with this function doesn't ensure the presence of
144
    /// required fields. Use [`Self::from_fields`] or [`Self::from_json`] if it's
145
    /// important for metadata to be valid.
146
    #[cfg(test)]
147
    pub(crate) fn add_field(&mut self, field: SupportedField) {
142✔
148
        self.0.insert(field.discriminant(), field);
142✔
149
    }
142✔
150

151
    /// Build `Metadata` object from the metadata fields, doing all necessary validation.
152
    pub(crate) fn from_fields<E>(
265✔
153
        fields: impl Iterator<Item = Result<SupportedField, E>>, report: &ProblemReport,
265✔
154
    ) -> Result<Self, E> {
265✔
155
        const REPORT_CONTEXT: &str = "Metadata building";
156

157
        let mut metadata = Metadata(HashMap::new());
265✔
158
        for v in fields {
1,292✔
159
            let v = v?;
1,027✔
160
            let k = v.discriminant();
1,027✔
161
            if metadata.0.insert(k, v).is_some() {
1,027✔
NEW
162
                report.duplicate_field(
×
NEW
163
                    &k.to_string(),
×
NEW
164
                    "Duplicate metadata fields are not allowed",
×
NEW
165
                    REPORT_CONTEXT,
×
NEW
166
                );
×
167
            }
1,027✔
168
        }
169

170
        if metadata.doc_type().is_err() {
265✔
171
            report.missing_field("type", REPORT_CONTEXT);
83✔
172
        }
182✔
173
        if metadata.doc_id().is_err() {
265✔
174
            report.missing_field("id", REPORT_CONTEXT);
75✔
175
        }
190✔
176
        if metadata.doc_ver().is_err() {
265✔
177
            report.missing_field("ver", REPORT_CONTEXT);
75✔
178
        }
190✔
179
        if metadata.content_type().is_err() {
265✔
180
            report.missing_field("content-type", REPORT_CONTEXT);
86✔
181
        }
179✔
182

183
        Ok(metadata)
265✔
184
    }
265✔
185

186
    /// Build `Metadata` object from the metadata fields, doing all necessary validation.
187
    ///
188
    /// # Errors
189
    ///   - Json deserialization failure.
190
    ///   - Duplicate fields.
191
    ///   - Missing mandatory fields like `id`, `ver`, `type`.
192
    pub fn from_json(fields: serde_json::Value) -> anyhow::Result<Self> {
61✔
193
        let fields = serde::Deserializer::deserialize_map(fields, MetadataDeserializeVisitor)?;
61✔
194
        let report = ProblemReport::new("Deserializing metadata from json");
61✔
195
        let metadata = Self::from_fields(fields.into_iter().map(anyhow::Result::<_>::Ok), &report)?;
61✔
196
        anyhow::ensure!(!report.is_problematic(), "{:?}", report);
61✔
197
        Ok(metadata)
61✔
198
    }
61✔
199

200
    /// Serializes the current `Metadata` object into the JSON object.
201
    ///
202
    /// # Errors
203
    ///   - Json serialization failure.
204
    pub fn to_json(&self) -> anyhow::Result<serde_json::Value> {
3✔
205
        let map = self
3✔
206
            .0
3✔
207
            .iter()
3✔
208
            .map(|(k, v)| Ok((k.to_string(), serde_json::to_value(v)?)))
14✔
209
            .collect::<anyhow::Result<serde_json::Map<_, _>>>()?;
3✔
210
        Ok(serde_json::Value::Object(map))
3✔
211
    }
3✔
212
}
213

214
impl Display for Metadata {
215
    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> {
×
216
        writeln!(f, "Metadata {{")?;
×
NEW
217
        writeln!(f, "  type: {:?},", self.doc_type().ok())?;
×
NEW
218
        writeln!(f, "  id: {:?},", self.doc_id().ok())?;
×
NEW
219
        writeln!(f, "  ver: {:?},", self.doc_ver().ok())?;
×
NEW
220
        writeln!(f, "  content_type: {:?},", self.content_type().ok())?;
×
NEW
221
        writeln!(f, "  content_encoding: {:?},", self.content_encoding())?;
×
NEW
222
        writeln!(f, "  additional_fields: {{")?;
×
NEW
223
        writeln!(f, "    ref: {:?}", self.doc_ref())?;
×
NEW
224
        writeln!(f, "    template: {:?},", self.template())?;
×
NEW
225
        writeln!(f, "    reply: {:?},", self.reply())?;
×
NEW
226
        writeln!(f, "    section: {:?},", self.section())?;
×
NEW
227
        writeln!(f, "    collaborators: {:?},", self.collaborators())?;
×
NEW
228
        writeln!(f, "    parameters: {:?},", self.parameters())?;
×
NEW
229
        writeln!(f, "  }},")?;
×
230
        writeln!(f, "}}")
×
231
    }
×
232
}
233

234
impl minicbor::Encode<()> for Metadata {
235
    /// Encode as a CBOR map.
236
    ///
237
    /// Note that to put it in an [RFC 8152] protected header.
238
    /// The header must be then encoded as a binary string.
239
    ///
240
    /// Also note that this won't check the presence of the required fields,
241
    /// so the checks must be done elsewhere.
242
    ///
243
    /// [RFC 8152]: https://datatracker.ietf.org/doc/html/rfc8152#autoid-8
244
    fn encode<W: minicbor::encode::Write>(
151✔
245
        &self, e: &mut minicbor::Encoder<W>, _ctx: &mut (),
151✔
246
    ) -> Result<(), minicbor::encode::Error<W::Error>> {
151✔
247
        e.map(
151✔
248
            self.0
151✔
249
                .len()
151✔
250
                .try_into()
151✔
251
                .map_err(minicbor::encode::Error::message)?,
151✔
NEW
252
        )?;
×
253
        self.0
151✔
254
            .values()
151✔
255
            .try_fold(e, |e, field| e.encode(field))?
468✔
256
            .ok()
151✔
257
    }
151✔
258
}
259

260
impl minicbor::Decode<'_, crate::decode_context::DecodeContext> for Metadata {
261
    /// Decode from a CBOR map.
262
    ///
263
    /// Note that this won't decode an [RFC 8152] protected header as is.
264
    /// The header must be first decoded as a binary string.
265
    ///
266
    /// Also note that this won't check the absence of the required fields,
267
    /// so the checks must be done elsewhere.
268
    ///
269
    /// [RFC 8152]: https://datatracker.ietf.org/doc/html/rfc8152#autoid-8
270
    fn decode(
206✔
271
        d: &mut Decoder<'_>, ctx: &mut crate::decode_context::DecodeContext,
206✔
272
    ) -> Result<Self, minicbor::decode::Error> {
206✔
273
        let mut map_ctx = match ctx.policy() {
206✔
274
            CompatibilityPolicy::Accept => {
275
                cbork_utils::decode_context::DecodeCtx::non_deterministic()
204✔
276
            },
277
            CompatibilityPolicy::Warn => {
278
                cbork_utils::decode_context::DecodeCtx::non_deterministic_with_handler(|error| {
1✔
279
                    tracing::warn!(
1✔
280
                        error = ?error,
NEW
281
                        "Catalyst Signed Document non deterministically encoded metadata field",
×
282
                    );
283
                    Ok(())
1✔
284
                })
1✔
285
            },
286
            CompatibilityPolicy::Fail => cbork_utils::decode_context::DecodeCtx::Deterministic,
1✔
287
        };
288

289
        let report = ctx.report().clone();
206✔
290
        let fields = cbork_utils::map::Map::decode(d, &mut map_ctx)?
206✔
291
            .into_iter()
204✔
292
            .map(|e| {
712✔
293
                let mut bytes = e.key_bytes;
712✔
294
                bytes.extend(e.value);
712✔
295
                Option::<SupportedField>::decode(&mut minicbor::Decoder::new(&bytes), ctx)
712✔
296
            })
712✔
297
            .filter_map(Result::transpose);
204✔
298

299
        Self::from_fields(fields, &report)
204✔
300
    }
206✔
301
}
302

303
/// Implements [`serde::de::Visitor`], so that [`Metadata`] can be
304
/// deserialized by [`serde::Deserializer::deserialize_map`].
305
struct MetadataDeserializeVisitor;
306

307
impl<'de> serde::de::Visitor<'de> for MetadataDeserializeVisitor {
308
    type Value = Vec<SupportedField>;
309

NEW
310
    fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
×
NEW
311
        f.write_str("Catalyst Signed Document metadata key-value pairs")
×
NEW
312
    }
×
313

314
    fn visit_map<A: serde::de::MapAccess<'de>>(self, mut d: A) -> Result<Self::Value, A::Error> {
61✔
315
        let mut res = Vec::with_capacity(d.size_hint().unwrap_or(0));
61✔
316
        while let Some(k) = d.next_key::<SupportedLabel>()? {
411✔
317
            let v = d.next_value_seed(k)?;
350✔
318
            res.push(v);
350✔
319
        }
320
        Ok(res)
61✔
321
    }
61✔
322
}
323

324
#[cfg(test)]
325
mod tests {
326
    use test_case::test_case;
327

328
    use super::*;
329

330
    #[test_case(
331
        serde_json::json!({
332
            "id": "0197f398-9f43-7c23-a576-f765131b81f2",
333
            "ver": "0197f398-9f43-7c23-a576-f765131b81f2",
334
            "type":  "ab7c2428-c353-4331-856e-385b2eb20546",
335
            "content-type": "application/json",
336
        }) ;
337
        "minimally valid JSON"
338
    )]
339
    #[test_case(
340
        serde_json::json!(
341
            {
342
                "id": "0197f398-9f43-7c23-a576-f765131b81f2",
343
                "ver": "0197f398-9f43-7c23-a576-f765131b81f2",
344
                "type":  "ab7c2428-c353-4331-856e-385b2eb20546",
345
                "content-type": "application/json",
346
                "ref":  [
347
                    {
348
                        "id": "0197f398-9f43-7c23-a576-f765131b81f2",
349
                        "ver": "0197f398-9f43-7c23-a576-f765131b81f2",
350
                        "cid": "0x",
351
                    },
352
                ]
353
            }
354
        ) ;
355
        "minimally valid JSON, new format reference type"
356
    )]
357
    #[test_case(
358
        serde_json::json!(
359
            {
360
                "id": "0197f398-9f43-7c23-a576-f765131b81f2",
361
                "ver": "0197f398-9f43-7c23-a576-f765131b81f2",
362
                "type":  "ab7c2428-c353-4331-856e-385b2eb20546",
363
                "content-type": "application/json",
364
                "ref": {
365
                    "id": "0197f398-9f43-7c23-a576-f765131b81f2",
366
                    "ver": "0197f398-9f43-7c23-a576-f765131b81f2",
367
                },
368
            }
369
        ) ;
370
        "minimally valid JSON, old format reference type"
371
    )]
372
    fn test_json_valid_serde(json: serde_json::Value) {
3✔
373
        let metadata = Metadata::from_json(json).unwrap();
3✔
374
        let json_from_meta = metadata.to_json().unwrap();
3✔
375
        assert_eq!(metadata, Metadata::from_json(json_from_meta).unwrap());
3✔
376
    }
3✔
377
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc