atrium_api/
types.rs

1//! Definitions for AT Protocol's data models.
2//! <https://atproto.com/specs/data-model>
3
4use crate::error::Error;
5use ipld_core::ipld::Ipld;
6use ipld_core::serde::to_ipld;
7use serde::{de, ser};
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::fmt;
11use std::ops::{Deref, DerefMut};
12
13mod cid_link;
14pub use cid_link::CidLink;
15
16mod integer;
17pub use integer::*;
18
19pub mod string;
20use string::RecordKey;
21
22/// Trait for a collection of records that can be stored in a repository.
23///
24/// The records all have the same Lexicon schema.
25pub trait Collection: fmt::Debug {
26    /// The NSID for the Lexicon that defines the schema of records in this collection.
27    const NSID: &'static str;
28
29    /// This collection's record type.
30    type Record: fmt::Debug + de::DeserializeOwned + Serialize;
31
32    /// Returns the [`Nsid`] for the Lexicon that defines the schema of records in this
33    /// collection.
34    ///
35    /// This is a convenience method that parses [`Self::NSID`].
36    ///
37    /// # Panics
38    ///
39    /// Panics if [`Self::NSID`] is not a valid NSID.
40    ///
41    /// [`Nsid`]: string::Nsid
42    fn nsid() -> string::Nsid {
43        Self::NSID.parse().expect("Self::NSID should be a valid NSID")
44    }
45
46    /// Returns the repo path for a record in this collection with the given record key.
47    ///
48    /// Per the [Repo Data Structure v3] specification:
49    /// > Repo paths currently have a fixed structure of `<collection>/<record-key>`. This
50    /// > means a valid, normalized [`Nsid`], followed by a `/`, followed by a valid
51    /// > [`RecordKey`].
52    ///
53    /// [Repo Data Structure v3]: https://atproto.com/specs/repository#repo-data-structure-v3
54    /// [`Nsid`]: string::Nsid
55    fn repo_path(rkey: &RecordKey) -> String {
56        format!("{}/{}", Self::NSID, rkey.as_str())
57    }
58}
59
60/// Definitions for Blob types.
61/// Usually a map with `$type` is used, but deprecated legacy formats are also supported for parsing.
62/// <https://atproto.com/specs/data-model#blob-type>
63#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
64#[serde(untagged)]
65pub enum BlobRef {
66    Typed(TypedBlobRef),
67    Untyped(UnTypedBlobRef),
68}
69
70/// Current, typed blob reference.
71#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
72#[serde(tag = "$type", rename_all = "lowercase")]
73pub enum TypedBlobRef {
74    Blob(Blob),
75}
76
77/// An untyped blob reference.
78/// Some records in the wild still contain this format, but should never write them.
79#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
80#[serde(rename_all = "camelCase")]
81pub struct UnTypedBlobRef {
82    pub cid: String,
83    pub mime_type: String,
84}
85
86#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
87#[serde(rename_all = "camelCase")]
88pub struct Blob {
89    pub r#ref: CidLink,
90    pub mime_type: String,
91    pub size: usize, // TODO
92}
93
94/// A generic object type.
95#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
96pub struct Object<T> {
97    #[serde(flatten)]
98    pub data: T,
99    #[serde(flatten)]
100    pub extra_data: Ipld,
101}
102
103impl<T> From<T> for Object<T> {
104    fn from(data: T) -> Self {
105        Self { data, extra_data: Ipld::Map(std::collections::BTreeMap::new()) }
106    }
107}
108
109impl<T> Deref for Object<T> {
110    type Target = T;
111
112    fn deref(&self) -> &Self::Target {
113        &self.data
114    }
115}
116
117impl<T> DerefMut for Object<T> {
118    fn deref_mut(&mut self) -> &mut Self::Target {
119        &mut self.data
120    }
121}
122
123/// An "open" union type.
124#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
125#[serde(untagged)]
126pub enum Union<T> {
127    Refs(T),
128    Unknown(UnknownData),
129}
130
131/// Data with an unknown schema in an open [`Union`].
132///
133/// The data of variants represented by a map and include a `$type` field indicating the variant type.
134#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
135pub struct UnknownData {
136    #[serde(rename = "$type")]
137    pub r#type: String,
138    #[serde(flatten)]
139    pub data: Ipld,
140}
141
142/// Arbitrary data with no specific validation and no type-specific fields.
143///
144/// Corresponds to [the `unknown` field type].
145///
146/// [the `unknown` field type]: https://atproto.com/specs/lexicon#unknown
147///
148/// By using the [`TryFromUnknown`] trait, it is possible to convert to any type
149/// that implements [`DeserializeOwned`](serde::de::DeserializeOwned).
150///
151/// ```
152/// use atrium_api::types::{TryFromUnknown, Unknown};
153///
154/// #[derive(Debug, serde::Deserialize)]
155/// struct Foo {
156///     bar: i32,
157/// }
158///
159/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
160/// let value: Unknown = serde_json::from_str(r#"{"bar": 42}"#)?;
161/// println!("{value:?}"); // Object({"bar": DataModel(42)})
162///
163/// let foo = Foo::try_from_unknown(value)?;
164/// println!("{foo:?}"); // Foo { bar: 42 }
165/// #     Ok(())
166/// # }
167/// ```
168#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
169#[serde(untagged)]
170pub enum Unknown {
171    Object(BTreeMap<String, DataModel>),
172    Null,
173    Other(DataModel),
174}
175
176#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
177#[serde(try_from = "Ipld")]
178pub struct DataModel(#[serde(serialize_with = "serialize_data_model")] Ipld);
179
180fn serialize_data_model<S>(ipld: &Ipld, serializer: S) -> Result<S::Ok, S::Error>
181where
182    S: ser::Serializer,
183{
184    match ipld {
185        Ipld::Float(_) => Err(ser::Error::custom("float values are not allowed in ATProtocol")),
186        Ipld::List(list) => {
187            if list.iter().any(|value| matches!(value, Ipld::Float(_))) {
188                Err(ser::Error::custom("float values are not allowed in ATProtocol"))
189            } else {
190                list.iter().cloned().map(DataModel).collect::<Vec<_>>().serialize(serializer)
191            }
192        }
193        Ipld::Map(map) => {
194            if map.values().any(|value| matches!(value, Ipld::Float(_))) {
195                Err(ser::Error::custom("float values are not allowed in ATProtocol"))
196            } else {
197                map.iter()
198                    .map(|(k, v)| (k, DataModel(v.clone())))
199                    .collect::<BTreeMap<_, _>>()
200                    .serialize(serializer)
201            }
202        }
203        Ipld::Link(link) => CidLink(*link).serialize(serializer),
204        _ => ipld.serialize(serializer),
205    }
206}
207
208impl Deref for DataModel {
209    type Target = Ipld;
210
211    fn deref(&self) -> &Self::Target {
212        &self.0
213    }
214}
215
216impl DerefMut for DataModel {
217    fn deref_mut(&mut self) -> &mut Self::Target {
218        &mut self.0
219    }
220}
221
222impl TryFrom<Ipld> for DataModel {
223    type Error = Error;
224
225    fn try_from(value: Ipld) -> Result<Self, Self::Error> {
226        // Enforce the ATProto data model.
227        // https://atproto.com/specs/data-model
228        match value {
229            Ipld::Float(_) => Err(Error::NotAllowed),
230            Ipld::List(list) => {
231                if list.iter().any(|value| matches!(value, Ipld::Float(_))) {
232                    Err(Error::NotAllowed)
233                } else {
234                    Ok(DataModel(Ipld::List(list)))
235                }
236            }
237            Ipld::Map(map) => {
238                if map.values().any(|value| matches!(value, Ipld::Float(_))) {
239                    Err(Error::NotAllowed)
240                } else {
241                    Ok(DataModel(Ipld::Map(map)))
242                }
243            }
244            data => Ok(DataModel(data)),
245        }
246    }
247}
248
249/// Trait for types that can be deserialized from an [`Unknown`] value.
250pub trait TryFromUnknown: Sized {
251    type Error;
252
253    fn try_from_unknown(value: Unknown) -> Result<Self, Self::Error>;
254}
255
256impl<T> TryFromUnknown for T
257where
258    T: de::DeserializeOwned,
259{
260    type Error = Error;
261
262    fn try_from_unknown(value: Unknown) -> Result<Self, Self::Error> {
263        // TODO: Fix this
264        // In the current latest `ipld-core` 0.4.1, deserialize to structs containing untagged/internal tagged does not work correctly when `Ipld::Integer` is included.
265        // https://github.com/ipld/rust-ipld-core/issues/19
266        // (It should be possible to convert as follows)
267        // ```
268        // Ok(match value {
269        //     Unknown::Object(map) => {
270        //         T::deserialize(Ipld::Map(map.into_iter().map(|(k, v)| (k, v.0)).collect()))?
271        //     }
272        //     Unknown::Null => T::deserialize(Ipld::Null)?,
273        //     Unknown::Other(data) => T::deserialize(data.0)?,
274        // })
275        // ```
276        //
277        // For the time being, until this problem is resolved, use the workaround of serializing once to a json string and then deserializing it.
278        let json = serde_json::to_vec(&value).unwrap();
279        Ok(serde_json::from_slice(&json).unwrap())
280    }
281}
282
283/// Trait for types that can be serialized into an [`Unknown`] value.
284pub trait TryIntoUnknown {
285    type Error;
286
287    fn try_into_unknown(self) -> Result<Unknown, Self::Error>;
288}
289
290impl<T> TryIntoUnknown for T
291where
292    T: Serialize,
293{
294    type Error = Error;
295
296    fn try_into_unknown(self) -> Result<Unknown, Self::Error> {
297        Ok(Unknown::Other(to_ipld(self)?.try_into()?))
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use ipld_core::cid::Cid;
305    use serde_json::{from_str, to_string};
306
307    const CID_LINK_JSON: &str =
308        r#"{"$link":"bafkreibme22gw2h7y2h7tg2fhqotaqjucnbc24deqo72b6mkl2egezxhvy"}"#;
309
310    #[test]
311    fn cid_link_serde_json() {
312        let deserialized =
313            from_str::<CidLink>(CID_LINK_JSON).expect("failed to deserialize cid-link");
314        let serialized = to_string(&deserialized).expect("failed to serialize cid-link");
315        assert_eq!(serialized, CID_LINK_JSON);
316    }
317
318    #[test]
319    fn blob_ref_typed_deserialize_json() {
320        let json = format!(
321            r#"{{"$type":"blob","ref":{},"mimeType":"text/plain","size":0}}"#,
322            CID_LINK_JSON
323        );
324        let deserialized = from_str::<BlobRef>(&json).expect("failed to deserialize blob-ref");
325        assert_eq!(
326            deserialized,
327            BlobRef::Typed(TypedBlobRef::Blob(Blob {
328                r#ref: CidLink::try_from(
329                    "bafkreibme22gw2h7y2h7tg2fhqotaqjucnbc24deqo72b6mkl2egezxhvy"
330                )
331                .expect("failed to create cid-link"),
332                mime_type: "text/plain".into(),
333                size: 0
334            }))
335        );
336    }
337
338    #[test]
339    fn blob_ref_untyped_deserialize_json() {
340        let json = r#"{"cid":"bafkreibme22gw2h7y2h7tg2fhqotaqjucnbc24deqo72b6mkl2egezxhvy","mimeType":"text/plain"}"#;
341        let deserialized = from_str::<BlobRef>(json).expect("failed to deserialize blob-ref");
342        assert_eq!(
343            deserialized,
344            BlobRef::Untyped(UnTypedBlobRef {
345                cid: "bafkreibme22gw2h7y2h7tg2fhqotaqjucnbc24deqo72b6mkl2egezxhvy".into(),
346                mime_type: "text/plain".into(),
347            })
348        );
349    }
350
351    #[test]
352    fn blob_ref_serialize_json() {
353        let blob_ref = BlobRef::Typed(TypedBlobRef::Blob(Blob {
354            r#ref: CidLink::try_from("bafkreibme22gw2h7y2h7tg2fhqotaqjucnbc24deqo72b6mkl2egezxhvy")
355                .expect("failed to create cid-link"),
356            mime_type: "text/plain".into(),
357            size: 0,
358        }));
359        let serialized = to_string(&blob_ref).expect("failed to serialize blob-ref");
360        assert_eq!(
361            serialized,
362            format!(
363                r#"{{"$type":"blob","ref":{},"mimeType":"text/plain","size":0}}"#,
364                CID_LINK_JSON
365            )
366        );
367    }
368
369    #[test]
370    fn blob_ref_deserialize_dag_cbor() {
371        // {"$type": "blob", "mimeType": "text/plain", "ref": bafkreibme22gw2h7y2h7tg2fhqotaqjucnbc24deqo72b6mkl2egezxhvy, "size": 0}
372        let dag_cbor = [
373            0xa4, 0x65, 0x24, 0x74, 0x79, 0x70, 0x65, 0x64, 0x62, 0x6c, 0x6f, 0x62, 0x63, 0x72,
374            0x65, 0x66, 0xd8, 0x2a, 0x58, 0x25, 0x00, 0x01, 0x55, 0x12, 0x20, 0x2c, 0x26, 0xb4,
375            0x6b, 0x68, 0xff, 0xc6, 0x8f, 0xf9, 0x9b, 0x45, 0x3c, 0x1d, 0x30, 0x41, 0x34, 0x13,
376            0x42, 0x2d, 0x70, 0x64, 0x83, 0xbf, 0xa0, 0xf9, 0x8a, 0x5e, 0x88, 0x62, 0x66, 0xe7,
377            0xae, 0x68, 0x6d, 0x69, 0x6d, 0x65, 0x54, 0x79, 0x70, 0x65, 0x6a, 0x74, 0x65, 0x78,
378            0x74, 0x2f, 0x70, 0x6c, 0x61, 0x69, 0x6e, 0x64, 0x73, 0x69, 0x7a, 0x65, 0x00,
379        ];
380        let deserialized = serde_ipld_dagcbor::from_slice::<BlobRef>(dag_cbor.as_slice())
381            .expect("failed to deserialize blob-ref");
382        assert_eq!(
383            deserialized,
384            BlobRef::Typed(TypedBlobRef::Blob(Blob {
385                r#ref: CidLink::try_from(
386                    "bafkreibme22gw2h7y2h7tg2fhqotaqjucnbc24deqo72b6mkl2egezxhvy"
387                )
388                .expect("failed to create cid-link"),
389                mime_type: "text/plain".into(),
390                size: 0,
391            }))
392        );
393    }
394
395    #[test]
396    fn data_model() {
397        assert!(DataModel::try_from(Ipld::Null).is_ok());
398        assert!(DataModel::try_from(Ipld::Bool(true)).is_ok());
399        assert!(DataModel::try_from(Ipld::Integer(1)).is_ok());
400        assert!(DataModel::try_from(Ipld::Float(1.5)).is_err(), "float value should fail");
401        assert!(DataModel::try_from(Ipld::String("s".into())).is_ok());
402        assert!(DataModel::try_from(Ipld::Bytes(vec![0x01])).is_ok());
403        assert!(DataModel::try_from(Ipld::List(vec![Ipld::Bool(true)])).is_ok());
404        assert!(
405            DataModel::try_from(Ipld::List(vec![Ipld::Bool(true), Ipld::Float(1.5)])).is_err(),
406            "list with float value should fail"
407        );
408        assert!(DataModel::try_from(Ipld::Map(BTreeMap::from_iter([(
409            String::from("k"),
410            Ipld::Bool(true)
411        )])))
412        .is_ok());
413        assert!(
414            DataModel::try_from(Ipld::Map(BTreeMap::from_iter([(
415                String::from("k"),
416                Ipld::Float(1.5)
417            )])))
418            .is_err(),
419            "map with float value should fail"
420        );
421        assert!(DataModel::try_from(Ipld::Link(
422            Cid::try_from("bafkreibme22gw2h7y2h7tg2fhqotaqjucnbc24deqo72b6mkl2egezxhvy")
423                .expect("failed to create cid")
424        ))
425        .is_ok());
426    }
427
428    #[test]
429    fn union() {
430        #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
431        #[serde(tag = "$type")]
432        enum FooRefs {
433            #[serde(rename = "example.com#bar")]
434            Bar(Box<Bar>),
435            #[serde(rename = "example.com#baz")]
436            Baz(Box<Baz>),
437        }
438
439        #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
440        struct Bar {
441            bar: String,
442        }
443
444        #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
445        struct Baz {
446            baz: i32,
447        }
448
449        type Foo = Union<FooRefs>;
450
451        let foo = serde_json::from_str::<Foo>(r#"{"$type":"example.com#bar","bar":"bar"}"#)
452            .expect("failed to deserialize foo");
453        assert_eq!(foo, Union::Refs(FooRefs::Bar(Box::new(Bar { bar: String::from("bar") }))));
454
455        let foo = serde_json::from_str::<Foo>(r#"{"$type":"example.com#baz","baz":42}"#)
456            .expect("failed to deserialize foo");
457        assert_eq!(foo, Union::Refs(FooRefs::Baz(Box::new(Baz { baz: 42 }))));
458
459        let foo = serde_json::from_str::<Foo>(r#"{"$type":"example.com#foo","foo":true}"#)
460            .expect("failed to deserialize foo");
461        assert_eq!(
462            foo,
463            Union::Unknown(UnknownData {
464                r#type: String::from("example.com#foo"),
465                data: Ipld::Map(BTreeMap::from_iter([(String::from("foo"), Ipld::Bool(true))]))
466            })
467        );
468    }
469
470    #[test]
471    fn unknown_serialize() {
472        #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
473        struct Foo {
474            foo: Unknown,
475        }
476
477        let foo = Foo {
478            foo: Unknown::Object(BTreeMap::from_iter([(
479                String::from("bar"),
480                DataModel(Ipld::String(String::from("bar"))),
481            )])),
482        };
483        let serialized = to_string(&foo).expect("failed to serialize foo");
484        assert_eq!(serialized, r#"{"foo":{"bar":"bar"}}"#);
485    }
486
487    #[test]
488    fn unknown_deserialize() {
489        #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
490        struct Foo {
491            foo: Unknown,
492        }
493
494        // valid: data object
495        {
496            let json = r#"{
497                "foo": {
498                    "$type": "example.com#foo",
499                    "bar": "bar"
500                }
501            }"#;
502            let deserialized = from_str::<Foo>(json).expect("failed to deserialize foo");
503            assert_eq!(
504                deserialized,
505                Foo {
506                    foo: Unknown::Object(BTreeMap::from_iter([
507                        (String::from("bar"), DataModel(Ipld::String(String::from("bar")))),
508                        (
509                            String::from("$type"),
510                            DataModel(Ipld::String(String::from("example.com#foo")))
511                        )
512                    ]))
513                }
514            );
515        }
516        // valid(?): empty object
517        {
518            let json = r#"{
519                "foo": {}
520            }"#;
521            let deserialized = from_str::<Foo>(json).expect("failed to deserialize foo");
522            assert_eq!(deserialized, Foo { foo: Unknown::Object(BTreeMap::default()) });
523        }
524        // valid(?): object with no `$type`
525        {
526            let json = r#"{
527                "foo": {
528                    "bar": "bar"
529                }
530            }"#;
531            let deserialized = from_str::<Foo>(json).expect("failed to deserialize foo");
532            assert_eq!(
533                deserialized,
534                Foo {
535                    foo: Unknown::Object(BTreeMap::from_iter([(
536                        String::from("bar"),
537                        DataModel(Ipld::String(String::from("bar")))
538                    )]))
539                }
540            );
541        }
542        // valid(?): null
543        {
544            let json = r#"{
545                "foo": null
546            }"#;
547            let deserialized = from_str::<Foo>(json).expect("failed to deserialize foo");
548            assert_eq!(deserialized, Foo { foo: Unknown::Null });
549        }
550        // valid(?): primitive types
551        {
552            let json = r#"{
553                "foo": 42
554            }"#;
555            let deserialized = from_str::<Foo>(json).expect("failed to deserialize foo");
556            assert_eq!(deserialized, Foo { foo: Unknown::Other(DataModel(Ipld::Integer(42))) });
557        }
558        // invalid: float (not allowed)
559        {
560            let json = r#"{
561                "foo": 42.195
562            }"#;
563            assert!(from_str::<Foo>(json).is_err());
564        }
565    }
566
567    #[test]
568    fn unknown_try_from() {
569        #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
570        #[serde(tag = "$type")]
571        enum Foo {
572            #[serde(rename = "example.com#bar")]
573            Bar(Box<Bar>),
574            #[serde(rename = "example.com#baz")]
575            Baz(Box<Baz>),
576        }
577
578        #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
579        struct Bar {
580            bar: String,
581        }
582
583        #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
584        struct Baz {
585            baz: i32,
586        }
587
588        {
589            let unknown = Unknown::Object(BTreeMap::from_iter([
590                (String::from("$type"), DataModel(Ipld::String(String::from("example.com#bar")))),
591                (String::from("bar"), DataModel(Ipld::String(String::from("barbar")))),
592            ]));
593            let bar = Bar::try_from_unknown(unknown.clone()).expect("failed to convert to Bar");
594            assert_eq!(bar, Bar { bar: String::from("barbar") });
595            let barbaz = Foo::try_from_unknown(unknown).expect("failed to convert to Bar");
596            assert_eq!(barbaz, Foo::Bar(Box::new(Bar { bar: String::from("barbar") })));
597        }
598        {
599            let unknown = Unknown::Object(BTreeMap::from_iter([
600                (String::from("$type"), DataModel(Ipld::String(String::from("example.com#baz")))),
601                (String::from("baz"), DataModel(Ipld::Integer(42))),
602            ]));
603            let baz = Baz::try_from_unknown(unknown.clone()).expect("failed to convert to Baz");
604            assert_eq!(baz, Baz { baz: 42 });
605            let barbaz = Foo::try_from_unknown(unknown).expect("failed to convert to Bar");
606            assert_eq!(barbaz, Foo::Baz(Box::new(Baz { baz: 42 })));
607        }
608    }
609
610    #[test]
611    fn serialize_unknown_from_cid_link() {
612        // cid link
613        {
614            let cid_link =
615                CidLink::try_from("bafkreibme22gw2h7y2h7tg2fhqotaqjucnbc24deqo72b6mkl2egezxhvy")
616                    .expect("failed to create cid-link");
617            let unknown = cid_link.try_into_unknown().expect("failed to convert to unknown");
618            assert_eq!(
619                serde_json::to_string(&unknown).expect("failed to serialize unknown"),
620                r#"{"$link":"bafkreibme22gw2h7y2h7tg2fhqotaqjucnbc24deqo72b6mkl2egezxhvy"}"#
621            );
622        }
623        // blob ref (includes cid link)
624        {
625            let cid_link =
626                CidLink::try_from("bafkreibme22gw2h7y2h7tg2fhqotaqjucnbc24deqo72b6mkl2egezxhvy")
627                    .expect("failed to create cid-link");
628            let blob_ref = BlobRef::Typed(TypedBlobRef::Blob(Blob {
629                r#ref: cid_link,
630                mime_type: "text/plain".into(),
631                size: 0,
632            }));
633            let unknown = blob_ref.try_into_unknown().expect("failed to convert to unknown");
634            let serialized = serde_json::to_string(&unknown).expect("failed to serialize unknown");
635            assert!(
636                serialized.contains("bafkreibme22gw2h7y2h7tg2fhqotaqjucnbc24deqo72b6mkl2egezxhvy"),
637                "serialized unknown should contain cid string: {serialized}"
638            );
639        }
640    }
641}